~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
401 lines · 20.8 KB Raw History

Integrity and trust

What each party in the system can do, and what stops it doing more. Each point here is drawn from the page that covers it in full, linked in each section.

Git data

Every git object is named by its hash. A fetch indexes each pack with git index-pack, and checks the pack's size against what it downloads. A pack is self-contained, with no thin deltas, so each can be indexed alone. A server that hands out an object cannot lie about it, even from a stale cache.

Where a ref points is the ref record in the owner's account. The owner's PDS is the authority on it, and knotless does not yet check ref records against the account's signed commits (see Known gaps).

Each ref has its own compare-and-swap. A push creates a claim keyed by the generation it read, in the same applyWrites as the ref update, and the PDS accepts only the first create of a key. A forced push still goes through the claim, so it loses to a concurrent move rather than overwriting it. Compaction deletes claims a day old, and a push refuses to write against refs read more than 12 hours ago, so an old claim is never replayed. Both figures are defaults: the 12 hours is half of KNOTLESS_CLAIM_GRACE_SECS. A deleted ref keeps its record, so its generation never repeats.

Records point at a repository by its owner's DID and its key, never by its name. A rename does not orphan them, and a later repository under the same name does not inherit them. A redirect is followed only within the account, and only to a repo record with the key it names. The remote helper pins the DID a named remote first resolved to, and refuses a later run whose URL resolves to another account.

Paths are checked before they reach git. knotless commit refuses a path that is not relative, has an empty, . or .. segment, a .git segment in any case, or a NUL or newline. The object API refuses a revision that is empty, starts with -, or holds a NUL, newline or space, so git never reads it as an option.

See How repositories are stored, Naming and identity and knotless-serve.

Who is trusted with what

The owner's PDS

The owner's PDS holds the repository, and can withhold its records and blobs. It cannot substitute a git object, since readers check every pack with index-pack. It is trusted for where refs point.

A cache

knotless-serve keeps a cache, never the source of truth. Whatever it holds can be thrown away and read again from the PDSes. It can serve stale data, or none, but every object it hands out is named by its hash. It refuses pushes: they go to the PDS or a gateway.

An appview's index lags behind the network. It leaves out what moderation removes, and listing a public repository's pulls and issues fails without one. Actions that start from one item, such as commenting and merging, read it straight from its author's account instead.

The Jetstream operator

Jetstream's frames are unsigned, so a reader cannot prove an event is real, and it has dropped events. A false event can at worst cause a needless sync or a wrong index row, since mirrors still sync from the PDS. A runner treats a frame only as a doorbell: it reads the repository again from the owner's PDS and runs what that gives. A forged frame can make the runner read, never run something the owner did not push. Sweeps and reconnects cover dropped events. Following the firehose instead costs more, and depends on no Jetstream operator.

A gateway

A gateway holds an OAuth session for the owner, and writes as the owner. The grant is narrow: the four knotless record types, blobs, and those collections in the owner's own spaces. It lasts up to two years if used at least every three months, and the owner revokes it from their PDS's account settings. Every reader still checks what a gateway writes with index-pack. Run your own, or use one you trust.

A gateway checks every caller's service token against the signing key in the issuer's DID document, then checks that the issuer is the owner, a collaborator, or a member of the organization the owner is, which it reads from its own index of the organization's records and space. Each token names the gateway as audience and the method as lxm. A gateway does not read checks: it pushes a collaborator's merge whatever they say. It takes nothing for an archived repository, from anyone.

An organization's gateway also holds its member list: it invites, removes and makes repositories for the organization's admins, who call it from their own logins, and takes members in and out of its spaces as they join and leave. It acts only for the organization, an admin who joined, or the member themselves. A hidden member is seen by the gateway and the organization's members, and named for each private repository's readers in its space; anyone else reading a public repository sees public members only, so merging, closing, labelling and queueing there take a public membership. Leaving takes effect as the member deletes their acceptance record, whether or not the gateway answers: its index stops counting them once it hears of it, though they read its private repositories until it takes them out.

See Gateway.

A server you log in to

Logging in to a server's pages gives it an OAuth session with every scope the CLI and MCP use, broader than a gateway's: the pages star, follow, react, change the account's profile, picture and email claims, and block with it, create, fork and manage repositories, run organizations as knotless org does, and read the private repositories you are a member of. A repository's settings are its owner's alone, which the server checks for each write, not only the pages; branches are made and deleted as the CLI makes them, by the owner directly and by a collaborator through the gateway. For an operator (KNOTLESS_ADMIN_DIDS), the cookie also stands in for the service token the moderation methods take, from the site's own origin only, so a page elsewhere cannot take something down in an operator's name. It is kept apart from the gateway's sessions, so a login never lets the gateway push as you: delegating from the pages' Access tab starts the gateway's own authorization request, which you approve at your PDS as knotless delegate has you do. The browser holds only a signed, HttpOnly cookie naming the account. A write from the pages takes that cookie, never a service token, even one naming the same account; it and logging out must come from the site's own origin, and a login finishes only in the browser that started it. Logging out revokes the session; revoking the grant from your PDS does the same. An account taken down on the server is logged out of its pages until it is restored, as is one its own host deactivates, takes down or suspends, until the host says it is active again. An account deleted at its host loses every session the server kept for it, the pages', MCP's and the gateway's, with everything the server indexed from it (Accounts that go). When the pages ask the server's own appview or gateway on the account's behalf, the server signs that service token itself instead of asking the account's PDS for one (Authentication): it could get one with the session anyway, so this trusts it with nothing more. Log in only to a server you would trust with your account. See Logging in.

Connecting an assistant to a server's /mcp gives the server the same session, kept for the assistant's tokens to act with. The server lets in any account but one taken down there: an operator stops an account's tokens by taking it down. See Status and MCP.

Deleting an account's knotless data from the pages is done with that session, and only by the account itself: the server takes the pages' cookie from its own origin alone, and the account named again in the call, so no page elsewhere, and no other account, starts it. Asked with a service token, as the command line asks, it deletes nothing, and only forgets the account once its PDS holds no knotless record, which anyone can read. See An account deleting its data.

The owner

The owner writes their own records with no gateway. Branch rules therefore bind the owner only as far as their own client keeps to them. Only the owner may force a merge past the merge gate. Archiving binds the owner the same way: only their own client refuses to push to an archived repository. Only the owner's rerun records, hides and blocks count.

Collaborators

Collaborators are the accounts the repo record names in collaborators. They push through the gateway, within the branch rules: the gateway refuses deleting a protected branch, moving it anywhere but forward, a merge commit on a linear one, and any move of one that takes pulls only through the merge queue, unless they are the runner that lands it. They cannot write to the owner's account directly. Their state and queue records count, and so do their email claims.

The gateway holds a collaborator's merge to the merge gate too, as the repository stands when it is pushed: into a branch whose rule asks for reviews, every move must be the merge of an open pull that is reviewed as it asks and whose checks passed, and anything else is refused, createCommit included; in a repository that names runners, a push whose commit says it merges a pull, its message ending with the pull's AT-URI, must be one whose checks passed. A collaborator may still push commits that say no such thing to a branch that asks for no reviews, as they could before. The runner that lands the merge queue is not held to it here: it held each pull to it as it queued and batched them.

A pull's author

Anyone who can read a repository can open a pull or issue on it, in their own account. Only an author changes what they wrote. A pull's author may close and reopen it, but readers believe merged only from the owner or a collaborator. Only the author may change the pull's head branch.

A pull runs as it moves only if trusted: its author and its head repository's owner are each the owner or a collaborator. An untrusted pull runs only when the owner asks, and then once. Trusted or not, a pull's run gets no secrets, and runs only under the docker executor.

The repo record's agents names accounts, such as a coding agent, whose pulls run as they move too, when each of the author and the head repository's owner is an agent or may push. Their runs are still untrusted, as anyone else's are: the agent's diff can be steered by whoever wrote the issue it works from, so a collaborator who asked for the work does not vouch for what it wrote. An account named as an agent is one even if it is a collaborator too, and a batch with an agent's pull in it is untrusted.

Anyone writing records

Anyone can write a state, queue or check record about any repository, in their own account. Readers believe a state only from the item's author, the owner or a collaborator, and a queue record only from the owner or a collaborator. They show only the checks of the accounts the repo record names in runners. A check's key starts with a hash of the owner's DID, which keeps one owner from naming another's repository with a key of their own.

The owner can hide an item, which takes its thread with it, and block an account from one repository or all of them. Readers then leave out what that account wrote.

Labelers

A server can follow a labeler with --labeler <did>. The labeler's own !takedown and !hide labels then hold there as if made there, until they expire or are negated. Labels are unsigned: a follower trusts the endpoint the labeler's DID document names. Takedowns and followed labels apply to public repositories in the appview, not to private ones, whose records never pass through it.

See Moderation.

Private repositories on the pages

A server shows a private repository only to a member logged in to it, read with that member's own session: their PDS mints the delegation token, and the owner's PDS issues the space credential only to a member. Nothing private is read with the server's own credentials, or one viewer's for another, and what is read is kept per viewer. Anyone else gets a 404 worded as for a repository never made. An answer that read anything private is Cache-Control: private, no-store. Membership is asked again once a minute, so a removed member reads it for at most a minute more; logging out ends it at once. Cards, feeds, search, stars, notifications and activity never show one. See Who can see a private repository.

A private repository's index

A server people log in to keeps an index of every private repository a member reads there, and of every one whose owner logs in there. It syncs each with the session of a member who read it there, or its owner's, asking afresh each time for a credential the owner's PDS gives only to a member, and forgets the space and all it gave once that fails. It answers from that index only a viewer proven a member, as above, and lists an owner's own on their profile to them alone. See An index of every one read here.

Runners and their executors

A runner runs only what the owner's PDS says a ref points at. Its checks count only if the repo record names its account in runners. What points it at a repository (the appview's listing, a Jetstream frame, an invite to a private one, which any account can leave for any runner) only says where to look: it watches one only once that record, read from the owner's PDS or space, names it. It lands a repository's merge queue only with --queue, and only if its own account is the owner or a collaborator, or the repository names it and no runner that may push. Such a runner lands through the owner's gateway, which takes from it only a batch of queued pulls, squashed in order, once the owner has delegated writes there (gateway.md). Whoever holds a runner's account can therefore land what a maintainer queued without its checks passing, and mark pulls merged, close issues and eject pulls from the queue, but push nothing else.

A run is the only other way a runner writes. A workflow's asked.commit names files that a run asked for by name may write back: the runner then commits what the steps changed among them onto the branch it ran at and pushes it. Only the owner's rerun records ask, so no one else can start one. It writes only to a branch that is neither the default nor protected, so never past the merge gate, and only from the commit it ran at, so never over a push. The workflow file comes from the branch itself, which only those who can already push there can change.

The docker executor runs each run in a container from the workflow's image. The shell executor runs each step as the runner's own user, who can read the secrets file and the runner's session. Watch a repository under it only if you trust everyone who can push to it with the host. --pulls and --queue refuse the shell executor.

Caches exist only under docker. Only a trusted run promotes a cache: a passing push to the default branch, a push there filed as passed from a trusted pull's or batch's run at the same tree (whose layers it takes), or, on a repository named with --pulls-only, a trusted pull's passing run into the default branch (runner.md). No untrusted pull or batch promotes one, and an untrusted run's layers are never kept. An untrusted pull or batch running a workflow that asks for secrets reads its caches from a base nothing writes, so always empty.

A runner's operator decides whose code it runs at all. --paused stops an owner's runs, or everyone's but the runner's own, at the next pass, each filed as an error saying so. Deleting checks is the runner's alone: it prunes only its own records (see Pruning).

See The runner.

Commit authors

Anyone can claim any email address, and nothing proves that an address belongs to the account claiming it. A page believes a claim only from the repository's owner and collaborators, and from a merged pull's author for the commits that pull brought in. Only the owner or a collaborator can merge, so the merge vouches for those commits. The owner and collaborators are matched first, so a pull's author cannot take their addresses.

Claims are hashes salted with the account's DID, so the record names no address. A commit knotless makes with no author named is authored as <did>@knotless.invalid, which needs no claim.

See Commit authors.

Secrets

Secrets come from the runner's secrets file, keyed by the repository's label. Once the repository is deleted, the runner takes its label's grants out of the file, so a new repository someone makes under the same name gets none until they are granted again. A secret reaches a step only if the workflow lists it, the file grants it to the repository and, when the secret names refs, to the ref. A pull's or a batch's run never gets one. A secret limited to refs/heads/main cannot be read from another branch, which matters because a branch can change the workflow itself.

Each step is given its secrets as its process starts, never the container, so a step sees only those it may. A step another repository shares sees only those its uses step passes it (Shared steps), and beside one, what a step given secrets leaves running is ended as it ends. It still runs in the same container as the workflow's own steps, though, and can read what they left on disk. A shared step is code the repository runs: its lock pins it to a commit, so it changes only through a change to the repository, and a pull whose lock brings steps from an account the repository does not vouch for runs untrusted until it lands.

An owner can also leave a repository's runners secrets at the appview, each sealed to one runner's key (The runner's key). The appview holds only ciphertext: the owner lists names and refs, never a value, and a runner lists only what was sealed to it. Each value is bound to the owner, the repository's key, the runner, its name and its refs, so one carried to another repository does not open. Base mode does not say who sealed a value, so the appview could swap one in, though never read one.

The repository's settings set them as the command does: the page seals each value in the browser, to the keys the runners publish, and sends only ciphertext. Those writes take the pages' login, so a request from another site's page is refused, as every page write is. The page's code comes from the appview, though, so an owner who does not trust it to seal honestly sets secrets with knotless secret instead.

Logs are masked before upload: each granted value, and each trimmed line of a multi-line value, becomes ***. Values under 4 bytes are left alone. Masking catches a secret only as written. A step can still leak one transformed, encoded, or a bit at a time.

Variables given to a run asked for by name are written at the top of its public log, and any named as one of the workflow's secrets is dropped.

See Secrets.

Web pages

A repository holds whatever its owner pushed, and a pull whatever a stranger wrote. The pages keep that content from running script on the site:

  • Pages carry a strict content security policy: scripts only from the site, and no other site may frame them.
  • Markdown keeps raw HTML as text. Links keep only http, https and mailto, and relative links resolve inside the repository.
  • A composer's preview (dev.knotless.renderMarkdown) is that same renderer, open to anyone without a login: it reads no repository and fetches nothing, a link's target is only written into the HTML, and the text is capped at a body's 20000 bytes, so it costs no more than drawing a comment.
  • Mermaid blocks arrive as code, and the browser draws them with Pele, which escapes text, drops script callbacks and blocks javascript: and data: URLs. Links in a diagram get rel="noopener nofollow ugc".
  • Raw files are served with a sandbox policy, nosniff, and a type from a short list: a few image types, plain text, or octet-stream. A raw HTML or SVG file never runs as a page of the site.
  • Check images and avatars are read only for a CID the record names, with the same sandbox.

A handle is shown for a DID only if the handle resolves back to that DID. A pull's checks show only at its current head, since checks of an older head would read as passing what never ran.

See Safe to show.

Known gaps

  • Ref records are not verified against the account's signed repo commits.
  • A merge through a gateway can move the branch but leave the pull open when the head branch's delete is refused.
  • Compaction may drop objects that a push in flight relies on. This race is not confirmed.