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 | ||