Revised per PR #480 (#5607 owner clarification + #5608 codex resolution; #5612 directs the update): - The audit row no longer implies upstream observation or agent-only authorship. Reworded the guarantee: the row cryptographically binds commit bytes (control-plane-RECOMPUTED SHA) to access to the activation signing key, and binds that key to control-plane-owned activation metadata. An agent can sign arbitrary contents but cannot verify as a different activation or choose the recorded metadata. - Control plane accepts gateway-delivered opaque bytes, independently recomputes the Git object ID, verifies the embedded signature against the activation key, and stamps its own metadata. Trusts no gateway SHA/key/verdict/metadata. No upstream fetch. - Purged overclaims: removed "a compromised gateway cannot fabricate an audit binding" (the sidecar holds the signing capability, so it can — and that's acceptable under the intended guarantee), plus "accepted push" / "introduced upstream" framing. - Resolved the control-plane-transport open question in-PRD (was left open; codex asked to resolve): transport is gateway bytes + recompute + verify; mirror-read is no stronger. Issue: #423 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
25 KiB
PRD prd-new: Per-bottle signed commits & audit attribution
- Status: Draft
- Author: didericis-claude
- Created: 2026-07-25
- Issue: #423
Summary
Give each bottled agent a per-activation signing key so that every commit it
produces is signed in the git-gate trust boundary (outside the bottle) and
recorded in bot-bottle's own host-owned audit store, which is the portable
source of truth. Each row cryptographically binds a commit's bytes (and their
control-plane-recomputed SHA) to access to that activation's signing key, and
binds the key to control-plane-owned activation metadata — bottle, host,
manifest, agent, activation interval, retained public key — plus the commit's
claimed author. The gate mints a short-lived Ed25519 key at spin-up, holds the
private half in the sidecar ssh-agent, and forwards only SSH_AUTH_SOCK into
the bottle. The gate rejects any commit it forwards that is not signed by the
activation key; separately, the control plane independently recomputes each
commit's object ID and verifies its signature before recording attribution — it
never trusts a SHA, key, or verdict asserted by the gate.
This PRD deliberately does not enforce or vouch author/committer identity. Author/committer name/email are recorded as claims carried inside the signed object; making the gate reject a mismatching author/committer is a possible future add (see Non-goals and Deferred: identity enforcement). Push capability stays exactly as PRD 0048 deploy keys; forge subuser accounts, provisioned API tokens, and forge-side status/"Verified" badges remain out of scope (a future "forge actors" PRD).
Successor to:
- PRD 0027 (agent git identity, #94) / ADR 0002 — established that
git-gate.username/email is claimed, not vouched. This PRD keeps that posture: it adds signed provenance and a durable host record, not identity enforcement. - PRD 0048 (deploy-key provisioning, #169) — the host-side mint-at-spin-up / revoke-at-teardown lifecycle the signing key follows. Deploy keys are unchanged.
- PRD 0070 (per-host orchestrator, #351) — the orchestrator/control plane is
the sole owner of
bot-bottle.db; audit verification and recording live there, not in the data-plane gate (see Trust boundary).
The guarantee
The crisp property this feature provides:
The host-owned audit store binds a set of commit bytes — whose Git object ID the control plane recomputes itself — to access to this activation's signing key, and binds that key to control-plane-owned activation metadata: bottle, host, manifest, agent, activation interval, retained public key. An agent may author and sign arbitrary commit contents, but it cannot make that signature verify as a different activation, and it cannot choose the activation metadata the control plane records. The commit's author/committer identity is recorded as a claim, not enforced or vouched. The forge remains only the repository transport/capability layer.
What this does and does not prove (issue #423, comments #5554 / #5607 / #5608):
- It proves access to activation Y's signing key: whoever assembled these commit bytes could sign with that key. Recomputing the object ID and verifying the embedded signature binds the SHA to activation Y, and the control plane's own records bind Y's key to Y's metadata.
- It does not prove the commit was ever pushed, observed upstream, kept (vs. later reverted or dropped), or produced by the agent rather than by any other holder of the activation signing capability (the sidecar itself). The store deliberately makes no claim about publication or sole-agent authorship — the owner's requirement is attribution of what manifest/agent/etc. was in use when a commit was signed, not proof of where the commit went (#5607).
- It does not make author/committer identity cryptographically vouched. The
bottle chooses every byte sent through the forwarded agent, so a signature over
author Mallory <mallory@example>is just as valid. Those fields are a claim carried inside the signed object and recorded as-is. - The binding is trustworthy because the control plane supplies the SHA (it recomputes it), the public key, and the activation metadata from its own state — never from a value the gateway asserts (see Trust boundary). The residual, by design: anything that holds the activation signing capability can produce commits that attribute to that activation. That is inherent to a binding on activation-key access, not a defect.
Problem
An agent runs on the developer's machine as a subrole, scoped down per role. Locally that is fine because the machine is single-tenant. The git history an agent produces, however, is a durable artifact that outlives the session and can be pushed to shared repositories, and today bot-bottle offers no tamper-evidence over it:
- No provenance. Nothing ties a pushed commit to the bottle/activation that
actually produced it.
git-gate.username/email is forgeable and cosmetic (ADR 0002); a commit could be produced anywhere. - No durable, portable record. There is no host-side ledger that says "SHA X was produced by agent A in bottle B on host H during interval [t0,t1], signed by key K," independent of any forge and surviving key rotation.
Goals / Success Criteria
- Per-activation signing key. A fresh Ed25519 keypair is minted host-side at
each activation; the private half lives only in the sidecar
ssh-agent, never in the bottle. OnlySSH_AUTH_SOCKcrosses the boundary. - Signed commits with no SHA divergence. Commits produced in the bottle are signed at commit time; the SHA the agent observes is the SHA that reaches the upstream through the gate.
- Gate rejects unsigned commits. Before the gate forwards a push, every newly-introduced commit (those not already reachable from the advertised upstream refs) must verify against the activation public key; a push with any unsigned or wrong-key new commit is rejected, loudly, with the offending SHA. This is a signature check only — no author/committer matching.
- Control-plane-owned attribution. The orchestrator/control plane (sole
owner of
bot-bottle.db, PRD 0070) recomputes each commit's object ID from the bytes, verifies the embedded signature against the activation public key it holds, and attaches activation metadata from its own state — accepting no SHA, key, verdict, or metadata asserted by the gateway. No upstream fetch is required. - Host is the source of truth. The audit record binds each recomputed SHA to the bottle, host, manifest, agent, activation interval, and retained public key, and records the commit's claimed author/committer.
- Verifiable after teardown. The audit record retains the full public
key, fingerprint, principal, and validity interval — enough to regenerate an
allowed-signers file and run
git verify-commitlong after the activation ends and the key is gone. - Reprovision-per-activation, fail-loud teardown. The signing key is minted once per activation (persists across restarts within that activation) and discarded at teardown; deploy-key revocation continues to follow PRD 0048's fail-loud discipline.
- Push capability unchanged. Forge access remains PRD 0048 deploy keys; no new forge API dependency beyond 0048's existing deploy-key registration.
Non-goals
- Author/committer enforcement. Explicitly out of scope for this PRD (issue #423, comment #5590). The gate does not reject a commit for carrying a foreign author or committer; those fields are recorded as claims. We rely on the cross-forge audit store of signed commits and the authors recorded there. See Deferred: identity enforcement for what a future add would look like.
- Cryptographically-vouched author identity. Not claimed — see The guarantee.
- Forge subuser accounts / provisioned API tokens / PAT minting. Dropped.
Gitea's
POST /users/:name/tokensrequires Basic Auth as the target user (an admin PAT cannot mint one for another user; the only server-side path is thegitea admin user generate-access-tokenCLI), so a token-minting bootstrap is a design in its own right (issue #423, comments #5518 / #5554). This PRD needs no subrole API token, so that bootstrap problem does not arise. - Forge-side attribution surfaces. No commit-status badges, no forge
"Verified" badge. The latter is doubly unsuitable: it renders dynamically
against a currently registered key (so it would lie the moment a
reprovisioned key is revoked), and on Gitea registering a signing key also
grants push. Attribution lives in the host record and local
git verify-commit, not the forge. - Non-Gitea forges, dashboard UI for orphan cleanup, mid-session rotation, dirty-teardown reconciliation. As before; a separate cleanup/sync pass handles orphans left by a crash or discarded snapshot.
Scope narrowing
This PRD started as "forge subroles" (forge subuser accounts + provisioned API tokens + optional forge status posting + signing). Review (issue #423, comments #5518 → #5590) narrowed it in two steps:
- Dropped the forge-account and API-token machinery (#5518 → #5556): the PAT bootstrap is not implementable as sketched (Basic-Auth-as-target-user constraint); the signature never vouched the author anyway; and making the host audit store the portable source of truth is a cleaner boundary that removes the forge-specific token lifecycle and commit-status dependence.
- Dropped author/committer enforcement (#5590): rely on the audit store of signed commits and the authors recorded there; gate enforcement of the identity fields is a possible future add, not part of this slice.
What remains is the core that stands on its own: signed commits + a host-owned, independently-verified audit record. Forge actors (a per-bottle account that comments/opens PRs) and identity enforcement (the gate rejecting a foreign author/committer) are each candidate future PRDs.
Design
Trust boundary (control plane vs data plane)
git-gate is the data plane: it parses hostile bytes from inside the bottle
and forwards pushes. The orchestrator is the control plane and, per PRD
0070, is the sole owner of bot-bottle.db. These are different trust boundaries,
and the audit record must be anchored in the control plane:
- The gate performs a synchronous pre-forward signature check (below) and can reject a push before it reaches the upstream. This is a data-plane gate on what leaves the bottle, not the audit binding.
- The control plane takes the commit bytes to attribute (gateway-delivered
opaque bytes are fine), recomputes the Git object ID, verifies the
embedded signature against the activation public key it minted and holds, and
writes
attributed_commitattaching metadata from its own state. It accepts no gateway-suppliedverifiedflag, claimed SHA, public key, or activation identity.
The precise trust statement (issue #423, review by didericis-codex on d8362ec,
resolved in #5608): the row binds these commit bytes / this recomputed SHA to
access to this activation's signing key, and the control plane binds that key
to the recorded activation metadata. It does not assert forge observation or
that only the agent (not the signing sidecar) authored the commit — so this PRD
does not claim a compromised gateway cannot obtain an attribution row.
Because the sidecar holds the activation signing capability, a compromised
gateway can assemble and sign a commit and have it attributed to that
activation; what it cannot do is make the signature verify as a different
activation or choose the metadata the control plane records. That residual is
acceptable under the intended guarantee (#5607) and is why the guarantee is
worded as activation-key access, not agent-only authorship or upstream
publication. The gate therefore cannot stand in for host-side verification: the
control plane recomputes the object ID and verifies the signature itself rather
than trusting the gate's word.
Identity model
Per bottled agent (agent definition ∘ sealed bottle), realized per activation:
| Part | Value | Source | Role |
|---|---|---|---|
| Signing key | one Ed25519 keypair | minted host-side per activation | signs every commit; private half sidecar-only; the anchor of provenance |
| Author/committer | name + email | git-gate.user (PRD 0027 overlay) |
written into commits and recorded as a claim; not enforced |
Manifest surface
No new top-level keys and no git-forge/forge-accounts blocks. A single
opt-in flag under the existing git-gate key turns on per-activation signing;
git-gate.user (PRD 0027) supplies the author string as today.
git-gate:
user: # PRD 0027 — author string; recorded, not enforced
name: didericis-claude
email: eric+claude@dideric.is
signing:
enabled: true # NEW — opt-in per-activation signing + audit
repos:
bot-bottle:
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
provisioned_key: # PRD 0048 — push capability, UNCHANGED
provider: gitea
token_env: GITEA_DEPLOY_TOKEN
host_key: "ssh-ed25519 AAAA..."
git-gate.signing.enabled: trueopts a bottle in. Without it, behavior is exactly as today. There is noenforcesub-key — this PRD does not enforce identity fields, so no knob is needed (and a knob that weakened a guarantee was flagged as a contradiction in review).git-gate.signingis bottle-only (home-only policy), rejected at the agent level with a clear pointer.git-gate.userkeeps its PRD 0027 agent-overlay semantics.
Signing: sign at commit time via a forwarded ssh-agent
The reason SHAs never diverge:
-
The sidecar (the git-gate trust boundary) runs an
ssh-agentholding the short-lived signing private key. -
Only
SSH_AUTH_SOCKis forwarded into the bottle — a bounded signing capability, not the key. -
The provisioner writes the bottle
.gitconfig:[commit] gpgsign = true [gpg] format = ssh [user] name = didericis-claude email = eric+claude@dideric.is signingkey = ssh-ed25519 AAAA... # activation signing PUBLIC key -
git commitasks the forwarded agent to sign; the signature is embedded at object creation, so the agent-space SHA equals the pushed SHA. No transcoder, no SHA translation table.
Gate pre-forward signature check (data plane)
The gate already fetches from upstream before every upload-pack and mirrors
bidirectionally (PRD 0008). When git-gate.signing.enabled is set, after
gitleaks and before forwarding a push upstream:
- Compute the newly-introduced set. Commits reachable from the pushed ref
tips but not reachable from any ref already advertised by the upstream
(which the gate knows because it fetches upstream first) — equivalent to
git rev-list <new-tips> --not <all-known-upstream-refs>. This excludes pulled/merged existing history; a merge commit the bottle creates is itself new and is checked, its already-upstream ancestors are not. - Verify each new commit's signature against the activation public key. A commit that is unsigned or signed by any other key causes the push to be rejected with the offending SHA.
- No author/committer matching is performed.
This is a synchronous safety gate on what leaves the bottle; it is not the audit record.
Control-plane verification & recording
For each commit to attribute (the gate hands the control plane the commit bytes; opaque gateway-delivered bytes are acceptable because nothing the gateway says about them is trusted), the orchestrator/control plane:
- Recomputes the Git object ID from the bytes itself. The stored
shais this recomputed value, never a SHA the gateway claims. - Verifies the embedded signature against the activation public key it
minted and holds for that activation (via a generated allowed-signers file) —
ignoring any
verifiedflag, key, or activation identity supplied by the gateway. - Writes
attributed_commitonly for bytes that pass, stamping the activation metadata (bottle/manifest/agent/host/interval) from its own state — not from anything the gateway provides — and recording the commit's claimed author/committer.
No upstream fetch is required: the guarantee is a byte↔activation-key binding, so the object does not need to come from the forge (issue #423, #5608). Bytes that do not verify against the activation key are not recorded as attributed (they may be logged as an anomaly instead).
Audit trail
The host SQLite store (PRD 0067, ~/.bot-bottle/bot-bottle.db, owned by the
control plane per PRD 0070) records the signing-key lifecycle and per-commit
attribution. Retention is the full public key, fingerprint, principal, and
validity interval — enough to regenerate an allowed-signers file and verify
commits after teardown (issue #423, comment #5554, resolution 3). Never any
private key material.
CREATE TABLE bottled_agent_activation (
bottled_agent_slug TEXT NOT NULL,
activation_id TEXT NOT NULL, -- one per activation cycle
host TEXT NOT NULL,
manifest_digest TEXT NOT NULL, -- ties the record to the sealed manifest
agent TEXT NOT NULL,
signing_pubkey TEXT NOT NULL, -- full ssh-ed25519 public key (for verify-commit)
signing_fpr TEXT NOT NULL, -- SHA256:... fingerprint (stable handle)
principal TEXT NOT NULL, -- allowed-signers principal, e.g. the author email
valid_from TEXT NOT NULL,
valid_until TEXT, -- NULL while active; set at teardown
status TEXT NOT NULL, -- active | retired
PRIMARY KEY (bottled_agent_slug, activation_id)
);
CREATE TABLE attributed_commit (
sha TEXT NOT NULL, -- control-plane-RECOMPUTED object ID, not gateway-claimed
bottled_agent_slug TEXT NOT NULL,
activation_id TEXT NOT NULL,
repo TEXT NOT NULL,
author_name TEXT NOT NULL, -- CLAIMED, recorded as-is (not enforced)
author_email TEXT NOT NULL, -- CLAIMED
committer_name TEXT NOT NULL, -- CLAIMED
committer_email TEXT NOT NULL, -- CLAIMED
observed_at TEXT NOT NULL,
PRIMARY KEY (sha, repo)
);
Verification/allowed-signers generation is a stated part of the design: for a
given SHA, join attributed_commit → bottled_agent_activation, emit
<principal> <signing_pubkey> to a temporary allowed-signers file, and
git verify-commit (or ssh-keygen -Y verify) against it. The
(pubkey, principal, valid_from/until) tuple is exactly what that requires. The
recorded author/committer columns are the claim; a consumer that wants to know
"who says they wrote this" reads them, understanding they are unenforced.
Credential lifecycle
Follows PRD 0048, minus the API-token kind (dropped):
- Activation: mint a fresh Ed25519 signing keypair; load the private half
into the sidecar
ssh-agent; write the public half into.gitconfigand thebottled_agent_activationrow (active,valid_fromset). Deploy keys are provisioned exactly as PRD 0048. Minting is per activation (a restart re-attaches the same key; a new activation mints a new key and retires the old row), so frozen snapshots don't accumulate live keys. - Teardown (fail-loud): revoke provisioned deploy keys via the forge API
(0048); discard the signing key from the sidecar agent and set the activation
row to
retiredwithvalid_until. The signing key was never on the forge, so there is nothing to revoke there — only the local retire. Deploy-key revocation failure halts teardown (0048); 404 = already-gone = success. - Dirty teardown is assumed handled; a separate cleanup/sync pass reconciles orphaned deploy keys.
Deferred: identity enforcement
If a future PRD wants the gate to enforce that new commits carry the manifest
identity, the natural shape is: extend the gate pre-forward check to also require
each new commit's author and committer name/email to equal git-gate.user,
rejecting mismatches — with the same control-plane re-verification before
recording. This is deliberately left out now (issue #423, comment #5590); it is
noted so the door stays open and the current schema (which records the claimed
author/committer) already carries what such a check would compare against. Note
that even then the property would be gate-enforced, not signature-vouched; a
validating signing broker in front of the key would be required for the latter.
Implementation chunks
- This PRD. Sets the (narrowed) design.
- Manifest surface. Add
git-gate.signing(bottle-only;enabledonly); reject it at the agent level. Unit tests for parse/validation and the agent-level rejection. - Signing pipeline. Sidecar
ssh-agentprovisioning; forwardSSH_AUTH_SOCKinto the bottle across docker, smolmachines, macOS-container, and firecracker backends; emit thecommit.gpgsign/gpg.format=ssh/user.signingkeygitconfig. Integration test: a bottle commit isverify-commit-valid and its SHA is unchanged through the gate; the private key is absent from the bottle. - Gate pre-forward signature check. Compute the newly-introduced set (excluding upstream-reachable commits), verify each against the activation key, reject unsigned/wrong-key with the offending SHA. Tests: unsigned rejected; wrong-key rejected; pulled/merged upstream history passes; an all-signed push succeeds. A foreign-author commit that is correctly signed passes the gate (identity is not enforced here).
- Control-plane verification + audit.
bottled_agent_activation/attributed_committables (PRD 0067 store, control-plane-owned per PRD 0070); the control plane recomputes each commit's object ID and verifies the signature before writing a row; retain full pubkey + fingerprint + principal + validity interval; record claimed author/committer; allowed-signers generation- a post-teardown
verify-commithelper. Tests: a gateway-claimed SHA/key/ verdict is ignored — the row'sshais the recomputed ID and bytes not signed by the activation key produce no row.
- a post-teardown
- Docs. Glossary entry ("per-bottle signed commits"); README manifest section; ADR note that signing-enabled bottles gain signed provenance and a host-owned audit record while authorship stays claimed (ADR 0002 unchanged).
Testing strategy
- Unit (must):
git-gate.signingparse/validation; agent-levelsigningrejection. - Integration — signing (must): end-to-end signed commit verifies with
git verify-commit; SHA observed in the bottle equals the SHA upstream; the private key is absent from the bottle. - Integration — gate check (must): unsigned rejected; wrong-key rejected; the upstream-reachable exclusion (pull + merge human history and push a signed merge); a correctly-signed foreign-author commit passes (no identity enforcement); a clean all-signed push succeeds.
- Control plane (must): the control plane recomputes the object ID and
records a row for bytes genuinely signed by the activation key; a gateway-
supplied SHA/key/verdict is ignored (the stored
shais the recomputed value); bytes signed by a foreign/invalid key produce no row. - Lifecycle: activation mints the key and writes an
activerow; teardown retires it (valid_until) and revokes deploy keys fail-loud; a restart re-attaches the same key (no new row); a fresh activation mints a new key and retires the old. - Post-teardown verification: regenerate the allowed-signers file from a
retiredrow and confirmverify-commitstill succeeds for an attributed SHA.
Resolved: control-plane transport
Raised in review and resolved (issue #423, #5608): the commit object does not need to come from the forge, and reading the gateway-owned mirror is no stronger than accepting gateway-delivered bytes — both are fabricatable, and neither matters because the control plane trusts nothing the gateway asserts. The transport is therefore: the gate hands the control plane the raw commit bytes, the control plane recomputes the object ID and verifies the signature against the activation key, and stamps its own activation metadata. No upstream fetch. This is exactly what makes the byte↔activation-key binding sound regardless of transport.
Open questions
- Where the gate check slots into PRD 0008 ordering. Modeled as a pre-forward step after gitleaks; confirm it composes with the existing access-hook / mirror ordering rather than needing a separate hook.