vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 4: the runner signs the result it reports
22528ac3da576387a8311969641a7b5783b04924 · Keenan Nemetz · 17d ago · parent 86fe5bb4
Phase 4: the runner signs the result it reports
An instance-key signature is the server witnessing itself. It cannot be checked
without trusting the box that produced it, and it says nothing about the party that
ran the steps — so a record signed that way would have been paperwork. The runner
already holds a key, the one it connects with, so the runner is what signs.
At finish the runner signs a claim: run, repo, ref, SHA, workflow blob, the digest of
the steps pinned when the job was queued, exit code, the log's {oid, size}, the
artifacts it published, and its own finishing time. The server rebuilds the same claim
from its own row plus what the runner reports, verifies the signature, and stores the
payload and signature verbatim — so a verifier re-checks the bytes that were signed
rather than re-deriving them from a format that might have drifted.
Who the signer is comes from the channel, not the claim: the account is the one the
SSH key resolved to, and the signature must verify under one of that account's keys.
A runner may therefore sign with a different key from the one it connected with — an
agent-held key to connect, a file to sign — but not as somebody else.
Refusals, all before anything is recorded, because half a record is worse than none:
an unsigned finish (an unattributed result is what the queue already had), a signature
by another account's key, and a signature over a different claim. The run page shows
`attested by <name> · key <fingerprint>`, and `vole runner` now pings first and refuses
to start work when the key it will sign with is not one its account holds — discovering
that after a ten-minute build is a wasted build.
`steps_sha` is recorded at enqueue and travels with the lease, so the claim covers the
steps that ran and not merely the workflow file they came from.
What this does not buy: truth. A runner can still lie about an exit code. It buys
accountability — the record is attributable to a party other than the instance.
Whether a required check must be attested by a particular runner (an ACL key defaulting
to the instance's own) is the next policy question and is deliberately not answered
here; any admin of the repository may still supply the signature.
Verified: TestAFinishedRunBelongsToTheRunnerThatSignsIt drives the real protocol over
SSH — the lease carries what the claim names, ping names the account and its keys,
unsigned and wrong-account and wrong-claim finishes are all refused with nothing
recorded, the correct one is accepted, and the stored payload still verifies under the
stored signature; TestClaimRendersOneWay pins the byte form (including artifact
ordering and an empty log) and TestClaimRefusesToForgeItself refuses a field carrying a
newline; TestStepDigestFollowsTheSteps shows the digest covering the steps themselves;
the cmd tests cover key discovery from VOLE_SSH_COMMAND/GIT_SSH_COMMAND, the missing-key
message, a non-key file, and the pre-work refusal; and the end-to-end test that runs the
real binary asserts the claim names the run, its log and its exit, and that the run page
says who attested it. scripts/ci.sh green (gofmt, vet, build, markdown links, go test
-race ./...).
docs/roadmap.md +35−11
| @@ -81,7 +81,9 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust | |||
| 81 | 81 | - ACL v1: owner / write / read, stored on `refs/vole/acl` | |
| 82 | 82 | - `pre-receive` + `update` hooks enforcing ACLs, protected branches, and object size limits | |
| 83 | 83 | - Web UI: repo list, tree/blob browser, raw view, commit log | |
| 84 | - Instance signing key generated at first boot and persisted (used from Phase 7) | ||
| 84 | - Instance signing key generated at first boot and persisted. It no longer signs run | ||
| 85 | records — those are signed by the runner that ran them (Phase 4 slice 4) — so its | ||
| 86 | remaining job is naming the instance at `/health` and on boot | ||
| 85 | 87 | - `vole index rebuild` implemented early and exercised by tests, even though the index has | |
| 86 | 88 | little to hold yet | |
| 87 | 89 | ||
| @@ -350,24 +352,45 @@ repository with no workflows is unaffected). The gate evaluates the approved hea | |||
| 350 | 352 | than a merge commit, since CI runs on what was reviewed. Runs are shown on the repository | |
| 351 | 353 | page and on each change request. | |
| 352 | 354 | ||
| 355 | ✅ **Slice 4 — the runner signs its result.** The party that did the work signs it: at | ||
| 356 | finish the runner signs a claim naming the run, the repository, the ref, the SHA, the | ||
| 357 | workflow blob, the job, the digest of the pinned steps, the exit code, the log's | ||
| 358 | `{oid, size}`, the artifacts it published and its own finishing time, and the server | ||
| 359 | records the claim and the signature verbatim. The signer is fixed by the channel, not | ||
| 360 | by the claim: the account comes from the SSH key that opened the connection, and the | ||
| 361 | signature must verify under one of *that* account's keys, so a runner can sign with a | ||
| 362 | different key from the one it connected with but not as somebody else. An unsigned | ||
| 363 | finish, a signature by another account's key and a signature over a different claim | ||
| 364 | are all refused, and nothing is recorded before the claim verifies — half a record is | ||
| 365 | worse than none. The run page says `attested by <name> · key <fingerprint>`, and | ||
| 366 | `vole runner` pings first and refuses to start work if the key it is about to sign | ||
| 367 | with is not one its account holds (learning that after a ten-minute build is a wasted | ||
| 368 | build). Steps are pinned at enqueue into a digest so the claim covers what ran, not | ||
| 369 | merely which workflow file it came from. | ||
| 370 | |||
| 371 | A signature adds accountability, not truth: a runner can still lie about an exit code, | ||
| 372 | and the claim records what it says, attributed to a key somebody is answerable for. | ||
| 373 | What it does buy is that the record is attributable to a party other than the instance, | ||
| 374 | which no instance-signed record could be. | ||
| 375 | |||
| 353 | 376 | Remaining: | |
| 354 | 377 | ||
| 355 | - Run records committed to a ref and signed by the instance key (Phase 7) | ||
| 356 | 378 | - Push-event queue persisted in SQLite, with at-least-once delivery | |
| 357 | - Runner protocol: `vole runner` registers, long-polls for work, leases a job, heartbeats; | ||
| 358 | an expired lease requeues the job | ||
| 359 | - Job execution: checkout by exact SHA from the server, sequential shell steps, per-step | ||
| 360 | timeout, logs uploaded to the content-addressed store when the attempt ends | ||
| 361 | - Run records pin SHA + workflow file hash + toolchain env; signed with the instance key | ||
| 379 | - Whether a required check must be *attested*, and by whom (`vole acl runners`): today a | ||
| 380 | signature is required but any admin of the repository may supply one, so "CI passed" | ||
| 381 | and "an account says CI passed" are still the same sentence | ||
| 382 | - Run records are not portable: they live in the queue, not on a ref, so a mirror cannot | ||
| 383 | carry them. Not planned unless the instance stops being the only party serving the | ||
| 384 | objects the claim names | ||
| 385 | - Toolchain environment in the claim (the runner's Go version, OS, architecture) — the | ||
| 386 | claim names the job, not the machine | ||
| 362 | 387 | - Statuses surfaced on CRs and satisfying required-check gating from Phase 3 | |
| 363 | - Log and artifact storage onto the content-addressed store (shared with Phase 5). | ||
| 364 | Logs moved in Phase 5 slice 4; workflow artifacts landed in Phase 7 slice 6 | ||
| 365 | 388 | ||
| 366 | 389 | **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in | |
| 367 | 390 | the job being requeued and completing on another runner ✅; a workflow that mutates its | |
| 368 | 391 | own definition mid-run still executes the version it was queued with ✅ (steps are pinned | |
| 369 | at enqueue); a run record can be verified against the SHA it claims — the record carries | ||
| 370 | it, but signing is Phase 7. | ||
| 392 | at enqueue); a run record can be verified against the SHA it claims ✅ (the runner signs | ||
| 393 | the claim, and the stored bytes still verify under the roster afterwards). | ||
| 371 | 394 | ||
| 372 | 395 | ### Phase 5 — LFS — ✅ complete (artifact uploads and locking deferred) | |
| 373 | 396 | ||
| @@ -832,6 +855,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 832 | 855 | | 2026-10-23 | The author of a metadata write is the account the key resolves to, asked with `vole-whoami`; `--author` is gone | `git config user.name` is a name somebody typed for commits: "Keenan Nemetz" sanitises to `keenan-nemetz`, which is not an account, so every issue filed from a normal machine was attributed to nobody and nothing complained. The instance is the only place that maps a key to an account, and it is one SSH verb away on the connection already open. A fallback exists for `--no-push` with the instance unreachable, and it warns loudly rather than guessing silently | | |
| 833 | 856 | | 2026-10-23 | A remote given as an SSH URL is fetched like a named one | Skipping the fetch for a URL is what made `--remote ssh://…` write on a branch that had never been updated: the first write in a fresh repository succeeded (nothing to fast-forward from) and every later one failed as a "lost race" that never happened, re-applying on the same stale tip and reporting "already exists". `git init` plus a URL is now the real no-clone filing path | | |
| 834 | 857 | | 2026-10-23 | The instance tells the client where it is served (`public_url`), so a command can print the page it wrote | A command that answers with an id leaves the reader to assemble a URL, and the instance is the only party that knows its own address behind a proxy. Optional: unset means ids only, which is what it printed before | | |
| 858 | | 2026-10-23 | A finished run is signed by the runner that ran it, not by the instance | An instance-key signature is the server witnessing itself: it cannot be checked without trusting the box that produced it, and it says nothing about the party that actually ran the steps. The runner already has a key — the one it connected with — so the claim binds repo, ref, SHA, workflow blob, step digest, exit, log object, artifacts and the runner's own finishing time, and the server verifies it against the account the SSH channel authenticated as. Consequences: the runner must have its private key on disk (nothing signs arbitrary bytes through an agent), an unsigned finish is refused, and "signed" still only means "attributed" — a runner can lie about an exit code. Whether a required check must be attested by a particular runner is the next policy question, deliberately not answered here | | ||
| 835 | 859 | | 2026-10-23 | A run log is uploaded by the runner, one object per attempt; nothing is streamed | Streaming made the server the author of bytes the runner produced: heartbeats shipped chunks, the server concatenated them in a SQLite column and hashed the result, so the archived object's id was the server's, not the writer's — and a signed record could never bind it. A log is now uploaded through the same put-object path as an artifact, and the store is the authority on whether the bytes exist. The cost is a live tail: nothing is visible until the attempt ends, and an attempt that is killed leaves no log at all (the attempt that hung is exactly the log somebody wants). Accepted, because a log that can be shown to be the runner's is worth more than one that arrives early | | |
| 836 | 860 | | 2026-10-23 | A review comment anchors on the blob of the content reviewed, and the page decides whether it is stale | A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse — any unrelated change to the branch would invalidate every anchor. The blob is content-addressed, so a comment stays glued to exactly what was read, current for as long as the file is unchanged, and an anchor that lies can only make itself stale. It also means nothing has to be checked at the push boundary: the page resolves the blob and compares, so a later head never retroactively rejects an old comment | | |
| 837 | 861 | | 2026-10-23 | A review comment is an issue-comment file inside the request, not a new format | `requests/<ulid>/comments/<ulid>.md` is parsed by the same code as `issues/<ulid>/comments/<ulid>.md` (`issues.ParseComment`), so the thread inherited the validation, the author-edits-own-words rule and the markdown boundary instead of reimplementing them. A request's own file stays outside the ownership rule for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit | | |