Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

One trust model: attribution at the boundary, and a log of what it decided

f50f6fabc7de2f2d33d22a4f2a8e5518ddb5becb · Keenan Nemetz · 16d ago · parent 671f2aa7

One trust model: attribution at the boundary, and a log of what it decided

The system had two answers to "who did this". Runner results were attributed
by a commit signature; issues, comments, approvals and every push were
attributed by the account the SSH handshake resolved. One was cryptographic
and lived in the object, three were boundary-based and lived in the
instance's word.

Keeping both meant keeping allowed_signers generation, ssh-keygen
-Y find-principals semantics, a --key contract in the runner, a pre-flight key
check before every build, a key column in the queue, and the standing
obligation to keep all of it agreeing with the mechanism used everywhere
else. The signature lost on three counts: it restated the handshake (a runner
signs with the key it already pushes with, so the only thing it added was
portability nobody consumes), it proved attribution rather than truth (a
runner can sign a lie), and an assurance mechanism nobody verifies is a
liability, because it looks like security.

So the signature is gone, and what replaced it is the thing a trust-the-box
model actually requires: a record. post-receive now writes one line per
accepted ref update — ref, old, new, account, repo — into the same
append-only log as the privileged acts. In post-receive rather than
pre-receive because pre-receive returning 0 is a permission and this is the
event, and it can never fail a push: the update has already landed, and
reporting it as rejected would make a client re-send something that worked.

Attribution is unchanged in substance — a record must still name the account
that pushed it, which is the rule an issue's author and an approval's owner
already follow — and the gate's three checks simplify to two, since "the
commit must be signed by that account's key" and "the signature's principal
must be that account" both went with the signature. What was a signed record
is now a plain commit on the metadata branch; the run page says "reported by"
and links the commit that carries it.

Deletions, net 467 lines: CommitFilesSigned and CommitTreeAsSigned collapse
into the unsigned forms, VerifyCommitSignature and signedByRE go,
allowedSigners/rosterKeys go with them, the runner loses loadSigningKey,
loadPrivateKey, publicKeyFile, keyFromSSHCommand and checkSigningKey along
with the --key flag and the ping round-trip that validated a key before work,
and Record.Key, Attestation.Fingerprint and their usage go from the record
format and the queue.

The docs now state the model once, in one place — README "The trust model"
and roadmap "The trust model" — including the cost, which is real: a clone
can no longer show who pushed what, and that is the wrong trade for an
instance mirrored outside its operator's control. If that arrives, the answer
is per-repository mandatory signing implemented once, not a second mechanism
kept warm in the meantime. Slices 4 and 6 of Phase 4 are marked removed with
their reasoning kept, because the proposal will come back.

Measured: TestPostReceiveRecordsThePush (both ref updates logged with actor
and account, a deletion logged as "-> none", the CI spool still written) and
TestPostReceiveSurvivesAnUnwritableLog (a full disk cannot fail a push);
TestARecordCannotBeAttributedToAnotherAccount and TestARecordNeedsNoSignature
replace the three signature tests; TestARecordMustDescribeTheLeasedJob stays,
because "the record must describe the job you were given" is a boundary check
and not a crypto one.

docs/roadmap.md +64−32

@@ -363,7 +363,12 @@ repository with no workflows is unaffected). The gate evaluates the approved hea
363 363 than a merge commit, since CI runs on what was reviewed. Runs are shown on the repository
364 364 page and on each change request.
365 365
366 ✅ **Slice 4 — the runner signs its result.** The party that did the work signs it: at
366 ✅ ~~**Slice 4 — the runner signs its result.**~~ **Removed** — see [the trust model](#the-trust-model)
367 below. The signature was superseded by boundary attribution: it restated the SSH handshake
368 for a verifier this deployment does not have, and it was the only place in the system with
369 a second, crypto-based answer to "who did this". The text is kept for the reasoning:
370
371 *The party that did the work signs it:* at
367 372 finish the runner signs a claim naming the run, the repository, the ref, the SHA, the
368 373 workflow blob, the job, the digest of the pinned steps, the exit code, the log's
369 374 `{oid, size}`, the artifacts it published and its own finishing time, and the server
@@ -407,7 +412,12 @@ not after it.
407 412 following git's way of editing a list (`config --add`, `config --unset-all`,
408 413 `remote set-url --add`) instead of inventing signs.
409 414
410 ✅ **Slice 6 — the runner publishes its result, as a signed commit.** A finished
415 ✅ ~~**Slice 6 — the runner publishes its result, as a signed commit.**~~ **Removed** together
416 with slice 4; the record is still a commit the runner pushes, but it is not signed, and what
417 `pre-receive` checks is that the record names the account that pushed it. The text is kept
418 for the reasoning:
419
420 *A finished
411 421 attempt is committed to `refs/heads/vole` by the **runner**:
412 422 `runs/<run id>/<attempt>/record.md` (run, repo, ref, sha, workflow, workflow blob,
413 423 job, steps digest, exit, finish time, signer, key) plus an LFS pointer for the log and
@@ -1002,36 +1012,57 @@ bounds it; proven by `TestReleaseArtifactDownload` and
1002 1012 or re-pointed ✅ (slice 4; proven by `TestPublishedReleaseIsImmutable`, which drives all
1003 1013 four refusals through a writer account).
1004 1014
1005 ### Deferred — signing and audit
1006
1007 Dropped from the active roadmap by decision: an instance owns its own keys and its own
1008 ACL, so a signature over an approval mostly restates what the SSH handshake already
1009 established. Revisit when an instance is mirrored somewhere its operator does not
1010 control, or when a third party must verify approvals without trusting the instance.
1011
1012 Measured findings, so the next session does not redo the experiment (git 2.53,
1013 OpenSSH 10.2):
1014
1015 - `git commit -S` and `git commit-tree -S` both sign with `gpg.format=ssh`, so the
1016 roster's keys are the signers and `vole request approve` could sign with one flag.
1017 - `git verify-commit` against a roster-derived `allowed_signers` accepts any key in the
1018 file and does **not** tie the signature to the committer email; identity comes from
1019 `ssh-keygen -Y find-principals`, not from the commit.
1020 - A signature covers a commit, not a file, so the rule would have to be "the commit
1021 that last touched `approvals/<account>.json` is signed by that account" — plus a
1022 format-version branch to grandfather existing approvals and mandatory signing
1023 configuration for every approver.
1024
1025 One version of this did turn out to be cheap, and it shipped: a **runner-signed
1026 commit** carries a run's record (Phase 4 slice 6), because one commit per attempt makes
1027 "the commit that last touched `runs/<id>/<attempt>/` is signed by the account it names"
1028 a rule that needs no format version and no extra file. The trigger for doing the same
1029 for approvals is approval-by-commit-signature, which needs approvers to hold signing
1030 keys and a grandfather rule for existing approvals.
1031
1032 If this returns, the cheaper shape is instance-signed push attestations plus a
1033 `vole verify` that replays the tree-checkable rules from a clone — not mandatory user
1034 signatures.
1015 ### The trust model
1016
1017 **One model, stated once.** Identity is established cryptographically at the SSH handshake
1018 and resolved to an account by the roster. Every decision about that account is made at one
1019 boundary — `pre-receive` — and every decision is written to one append-only log,
1020 `data/audit.log`. Nothing else in the system claims to prove anything.
1021
1022 That is the whole of it, and it exists as a section because it used to be two answers. A
1023 runner's result was attributed by a commit signature; an issue's author, a comment's and an
1024 approval's were attributed by the account whose key pushed. One path was cryptographic and
1025 lived in the object, three were boundary-based and lived in the instance's word. Keeping
1026 both meant keeping `allowed_signers` generation, `ssh-keygen -Y find-principals` semantics,
1027 a `--key` contract in the runner, a key column in the queue, a pre-flight key check before
1028 every build, and the standing obligation to keep all of that agreeing with the other three.
1029
1030 **The signature was removed, and the reasoning is worth keeping** because it will be
1031 proposed again. It lost on three points:
1032
1033 - *It restated the handshake.* The runner signs with the key it already pushes with, so a
1034 reader learns nothing the SSH channel had not already established — except portability,
1035 which is a property nobody in this deployment consumes.
1036 - *It proved attribution, not truth.* A runner could sign a lie. The roadmap already said
1037 so; what it did not say is that a mechanism which nobody verifies is a liability, because
1038 it looks like assurance.
1039 - *It was one path where every other fact had another.* An irregularity in a system whose
1040 whole design is "one enforcement point" is not a feature.
1041
1042 **What replaced it: the push log.** Attribution is the boundary's, so what the instance owes
1043 a reader is a record of what it decided. `post-receive` writes one line per accepted ref
1044 update — `push <ref> <old> -> <new> by <name> (<account id>) in <repo>` — into the same log
1045 as the privileged acts (force-pushes, lock overrides, release rewrites, merges through
1046 review, roster and ACL changes). It is written in `post-receive` rather than `pre-receive`
1047 because that is the point at which the refs have actually moved: pre-receive returning 0 is
1048 a permission, not an event. It cannot fail a push.
1049
1050 Measured, so the next person does not have to redo it (git 2.53, OpenSSH 10.2): `git
1051 commit -S` and `git commit-tree -S` sign with `gpg.format=ssh`, so the roster's keys could
1052 sign; `git verify-commit` against a roster-derived `allowed_signers` accepts any key in the
1053 file and does **not** tie the signature to the committer email, so identity comes from
1054 `ssh-keygen -Y find-principals` and not from anything the commit says; and a signature
1055 covers a commit rather than a file, which is why the rule would have had to be "the commit
1056 that last touched this path is signed by the account it names" — workable, and exactly what
1057 the run records did.
1058
1059 **The cost, stated plainly:** a clone cannot show who pushed what. A reader of a bare
1060 repository has to trust that the instance enforced its rules, and that its log is honest.
1061 That is the trade, and it is the wrong trade for one kind of deployment — an instance
1062 mirrored somewhere its operator does not control, or a third party who must verify without
1063 trusting the box. If that arrives, the answer is mandatory signing *for the repositories
1064 that need it* (`vole acl signing`), implemented once, with a `vole verify` that replays the
1065 rules from a clone — not a second mechanism kept warm in the meantime.
1035 1066
1036 1067 ### Phase 8 — Operate it
1037 1068
@@ -1167,6 +1198,7 @@ Filled in as phases land. Append; don't rewrite.
1167 1198 | 2026-10-23 | `runs-on` labels are pinned at enqueue, and a runner's labels are a claim rather than a grant | Pinning reuses the rule that already governs steps and artifact patterns: a workflow edited after a push must not change what an already-queued job is. That settles the derived-store question too — `run_labels` is queue state, not history, because a finished run's requirement is recoverable from the pinned workflow blob, so `index rebuild` does not restore labels and nothing needs it to. The asymmetry with `--repos` is the interesting part: a runner's labels are a claim about itself, so lying only wins it work it will do badly, while a runner's repository list is enforced by the server because an SSH flag must not become an access-control decision. Both are in the same lease request and neither is trusted for the same reason. Labels are lowercase and validated by `pre-receive` and by the runner with one shared rule, because a label no runner can carry leaves a job queued forever behind an error nobody sees |
1168 1199 | 2026-10-23 | LFS locking rides the SSH transfer protocol, and the refusal lives in `pre-receive` | The HTTP locking API is a write surface, and this instance has none: HTTP is anonymous read-only. The SSH adapter defines `lock`, `list-lock` and `unlock`, so locking costs no new authentication — the key that pushed is the key that locks. Where the refusal lives is the part worth remembering. git-lfs checks locks on the *client*, which means a plain `git push`, or a client with its hook disabled, walks past a lock entirely — so the rule also sits in `pre-receive`, at the one place this forge enforces everything else. The client-side check becomes a faster error message rather than the authority. Narrow on purpose: only the paths the push writes, so branching and merging untouched history are unaffected. Admin override is audited, for the same reason force-push is: a lock whose holder has left would otherwise make a path unpushable forever |
1169 1200 | 2026-10-23 | An LFS object is served only through a repository that holds a link to it | The store is content-addressed, so a plain lookup by id let anybody who knew a sha256 read another repository's artifact through a repository whose read rule they passed. Two fixes were available and one was rejected: an oid→repo table would have made the read path depend on derived state, so deleting index.db — which the project promises loses nothing — would have broken every LFS fetch until a rebuild. Hard links into a per-repository namespace keep the promise (the links are reconciled from the refs, so they are rebuildable too) and keep the deduplication (one copy of the bytes, two directory entries). The consequences are the interesting part: pre-receive has to check pointers against the *pushing* repository or a cross-repo pointer passes the push and 404s on clone; the sweep has to walk per repository, which makes it the repair tool as well; and an instance upgrading from the shared pool has no namespaces, so the server runs the link pass on first boot rather than leaving every fetch broken behind an upgrade note |
1201 | 2026-10-23 | One trust model: attribution at the push boundary, one log of what the boundary decided | The system had two answers to "who did this": runner results were attributed by a commit signature, and issues, comments, approvals and every push by the account whose key the SSH handshake resolved. Keeping both meant keeping allowed_signers generation, find-principals semantics, a --key contract in the runner, a key column in the queue and a pre-flight key check — and the obligation to keep all of it agreeing with the mechanism used everywhere else. The signature lost on three counts: it restated the handshake (the runner signs with the key it already pushes with, so the only thing it added was portability nobody consumes), it proved attribution rather than truth (a runner can sign a lie), and an unverified assurance mechanism is a liability because it looks like security. What replaced it is the thing a trust-the-box model actually requires: a push log. post-receive records one line per accepted ref update — ref, old, new, account, repo — in the same append-only log as the privileged acts, because if attribution is the boundary's then the instance owes a reader a record of its decisions. post-receive and not pre-receive, because pre-receive returning 0 is a permission and post-receive is the event, and it must never fail a push. The cost is stated in the roadmap: a clone can no longer show who pushed what, and that is the wrong trade for an instance mirrored outside its operator's control — at which point the answer is per-repository mandatory signing implemented once, not a second mechanism kept warm |
1170 1202
1171 1203 ## Open questions
1172 1204