# 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 introduces 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 binding each signed commit to a bottle, host, manifest, agent, activation interval, and retained public key — along with 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 newly-introduced commit that is not signed by the activation key; the **control plane** independently re-verifies each signature before recording attribution. 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.user` name/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: > Every new commit introduced through this bottle's gate is **signed by the > activation key**, and the **host-owned audit store** — after the control plane > independently verifies that signature — records it, binding the SHA to the > bottle, host, manifest, agent, activation interval, and retained public key. > 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 the signature does and does not prove (issue #423, comment #5554): - The signature proves **bottle/activation provenance**: the commit was created with access to activation *Y*'s signing key, i.e. inside this bottle during this activation. Independent verification binds SHA *X* to activation *Y*. - The signature does **not** make the author name/email cryptographically vouched. The bottle chooses every byte sent through the forwarded agent, so a raw signature could sign `author Mallory ` just as validly. Author/committer identity is a claim carried inside the signed object and recorded as-is. - The audit record is a cryptographic binding only because the **control plane** verifies the signature before recording — not because the data-plane gate asserts it (see **Trust boundary**). ## 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.user` name/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. Only `SSH_AUTH_SOCK` crosses 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) independently verifies each new commit's signature and only then writes the attribution row. A gateway assertion alone never creates an audit record. - **Host is the source of truth.** The audit record binds each attributed 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-commit` long 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/tokens` requires Basic Auth *as the target user* (an admin PAT cannot mint one for another user; the only server-side path is the `gitea admin user generate-access-token` CLI), 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: 1. **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. 2. **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, not the audit binding. - The **control plane independently verifies** each new commit's signature — receiving the commit object + signature (or independently reading/fetching the objects it is asked to attribute) — and only then writes `attributed_commit`. A gateway assertion alone never produces a row. If the gateway is compromised, the worst it can do is forward or reject pushes; it cannot fabricate an audit binding, because the control plane re-verifies against the activation public key it minted and holds. The gate cannot stand in for host-side audit verification (issue #423, review by didericis-codex on d8362ec). ### 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. ```yaml 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: true` opts a bottle in. Without it, behavior is exactly as today. There is **no `enforce` sub-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.signing` is **bottle-only** (home-only policy), rejected at the agent level with a clear pointer. `git-gate.user` keeps 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-agent` holding the short-lived signing private key. - **Only `SSH_AUTH_SOCK`** is forwarded into the bottle — a bounded signing capability, not the key. - The provisioner writes the bottle `.gitconfig`: ```ini [commit] gpgsign = true [gpg] format = ssh [user] name = didericis-claude email = eric+claude@dideric.is signingkey = ssh-ed25519 AAAA... # activation signing PUBLIC key ``` - `git commit` asks 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: 1. **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 --not `. This excludes pulled/merged existing history; a merge commit the bottle creates is itself new and is checked, its already-upstream ancestors are not. 2. **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. 3. 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 On an accepted push (or asynchronously, driven by the accepted-push event), the orchestrator/control plane: 1. Obtains the commit object and its signature for each newly-introduced SHA — either delivered by the gate as opaque bytes to be checked, or read/fetched independently from the mirrored repository. Either way the control plane does not trust a "verified" flag from the gate. 2. **Independently verifies** the signature against the activation public key it minted and holds for that activation (via a generated allowed-signers file). 3. Writes `attributed_commit` only for SHAs that pass, recording the commit's claimed author/committer alongside the activation binding. A SHA the control plane cannot independently verify is **not** recorded as attributed (it 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. ```sql 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, 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 ` ` 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 `.gitconfig` and the `bottled_agent_activation` row (`active`, `valid_from` set). 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 `retired` with `valid_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 1. **This PRD.** Sets the (narrowed) design. 2. **Manifest surface.** Add `git-gate.signing` (bottle-only; `enabled` only); reject it at the agent level. Unit tests for parse/validation and the agent-level rejection. 3. **Signing pipeline.** Sidecar `ssh-agent` provisioning; forward `SSH_AUTH_SOCK` into the bottle across docker, smolmachines, macOS-container, and firecracker backends; emit the `commit.gpgsign` / `gpg.format=ssh` / `user.signingkey` gitconfig. Integration test: a bottle commit is `verify-commit`-valid and its SHA is unchanged through the gate; the private key is absent from the bottle. 4. **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). 5. **Control-plane verification + audit.** `bottled_agent_activation` / `attributed_commit` tables (PRD 0067 store, control-plane-owned per PRD 0070); the control plane independently verifies each new commit's signature before writing a row; retain full pubkey + fingerprint + principal + validity interval; record claimed author/committer; allowed-signers generation + a post-teardown `verify-commit` helper. Test that a gateway assertion without an independently-verifiable signature produces **no** attribution row. 6. **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.signing` parse/validation; agent-level `signing` rejection. - **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):** independent verification records an attribution row for a genuinely-signed SHA; a SHA presented with an invalid/foreign signature (or a bare gateway assertion) produces **no** row. - **Lifecycle:** activation mints the key and writes an `active` row; 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 `retired` row and confirm `verify-commit` still succeeds for an attributed SHA. ## Open questions - **Control-plane transport for verification.** How does the orchestrator obtain the commit object + signature to verify — pushed to it by the gate as opaque bytes, or read/fetched independently from the mirror? Independent read is the stronger boundary; confirm it composes with the mirror layout (PRD 0008) and the orchestrator's repo access (PRD 0070). - **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.