chadtmiller.com/knotless
git that lives in your Atmosphere account
7
# 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](#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](storage.md), [Naming and
identity](identity.md) and [knotless-serve](serve.md#git-and-the-object-api).
## 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](gateway.md).
### 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](serve.md#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](gateway.md#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](serve.md#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](serve.md#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](serve.md#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](collaboration.md#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](access.md#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](access.md#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](gateway.md#runners-landing-a-queue)). 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](runner.md#caches)). 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](runner.md#pruning)).
See [The runner](runner.md).
## 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](identity.md#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](reusable-workflows.md)), 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](runner.md#the-runners-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](runner.md#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](serve.md#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.