# PRD prd-new: Per-bottle signed identity & audit attribution - **Status:** Draft - **Author:** didericis-claude - **Created:** 2026-07-25 - **Issue:** #423 ## Summary Give each bottled agent a **per-activation signing identity** whose commits are signed in the git-gate trust boundary (outside the bottle) and whose authorship is **enforced at the gate** before any push is accepted. The gate mints a short-lived Ed25519 signing key at spin-up, holds the private half in the sidecar `ssh-agent`, and forwards only `SSH_AUTH_SOCK` into the bottle. On push, the gate examines every newly-introduced commit and rejects the push unless each one is signed by the activation key **and** carries the manifest identity in its author and committer fields. bot-bottle's own immutable host-side records — not the forge — are the portable source of truth binding each commit to a bottle, host, manifest, agent, activation interval, and retained public key. This is deliberately narrower than the original "forge subroles" sketch (see **Scope narrowing** below). Push capability stays exactly as PRD 0048 deploy keys; forge subuser accounts, provisioned API tokens, and forge-side status/ "Verified" badges are dropped from this PRD and deferred to a possible 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*, and deferred integrity to "a commit-*signing* concern (SSH/GPG)." This PRD is that concern, but it is careful about *what* is proven: see **The guarantee**. - **PRD 0048 (deploy-key provisioning, #169)** — the host-side mint-at-spin-up / revoke-at-teardown lifecycle the signing key follows. Deploy keys themselves are unchanged. ## The guarantee The crisp property this feature provides: > Every new commit introduced through this bottle's gate is **signed by the > activation key**, **carries the manifest identity in its author and committer > fields** (enforced by the gate before the push is accepted), and is **tied by > immutable host-side records** to the bottle, host, manifest, agent, activation > interval, and retained public key. The forge remains only the repository > transport/capability layer. What the signature does and does not prove, stated plainly (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. Verifying the signature binds SHA *X* to activation *Y*. - The signature does **not** by itself 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**. - That claim is made trustworthy not by the signature cryptography but by the **gate acceptance check** (the gate refuses to push any new commit whose author/committer identity does not match the manifest) plus the **host audit record** (which observes and verifies the signature before marking the SHA attributed). The immutable record is a cryptographic binding only because the host verifies the signature before recording it — otherwise it is a bare assertion. The Goals and ADR language below are written to this weaker-but-honest guarantee rather than the earlier "vouched author identity / one cryptographic identity" framing. ## 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: - **Attribution is unverifiable after the fact.** A bottle commits under whatever `git-gate.user` name/email the manifest declares, which ADR 0002 accepts as forgeable and cosmetic (`git config user.email …` at runtime). Nothing ties a commit to the bottle/activation that actually produced it, and nothing stops a commit pushed through the gate from claiming an arbitrary author. - **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-enforced identity.** Before the gate accepts a push, every newly-introduced commit must (a) verify against the activation public key and (b) carry the manifest identity in **both** its author and committer name/email. Commits already reachable from the advertised upstream refs are excluded so pulling/merging existing (e.g. human-authored) history does not fail validation. A push containing any non-conforming new commit is rejected, loudly, with the offending SHA. - **Host is the source of truth.** An immutable host-side record binds each attributed SHA to the bottle, host, manifest, agent, activation interval, and the retained public key. The host verifies the observed signature *before* recording/marking a SHA attributed. - **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. This PRD adds no new forge API dependency beyond 0048's existing deploy-key registration. ## Non-goals - **Cryptographically-vouched author identity.** Explicitly *not* claimed — see **The guarantee**. The signature proves activation provenance; author/committer identity is enforced by the gate + recorded by the host, not vouched by the signature. (A future validating signing *broker* that parses the commit payload before signing could strengthen this; deferred.) - **Forge subuser accounts.** No per-bottle forge account (`didericis-claude` as a distinct Gitea user), no collaborator management. Deferred to a future "forge actors" PRD. - **Provisioned forge 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 non-trivial design in its own right (issue #423, comments #5518 / #5554). Because this PRD needs no subrole API token, that bootstrap problem does not arise here at all. - **Forge-side attribution surfaces.** No commit-status badges, no forge "Verified" badge. The latter is doubly unsuitable: it is rendered 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 → #5556) converged on dropping the forge-account and API-token machinery from the initial slice, for three reasons: 1. **The PAT bootstrap is not implementable as originally sketched.** The least-privilege minting credential cannot mint a PAT for another Gitea user over the documented HTTP API (Basic-Auth-as-target-user constraint), so the original implementation chunk for forge-account provisioning had no supported path without either the server-local admin CLI or custody of each subuser's Basic-Auth secret. 2. **The signature never vouched the author anyway** (see **The guarantee**), so the forge-account layer was not what made attribution trustworthy — the gate check and host record are. 3. **Making the host audit record the portable source of truth** is a cleaner boundary that removes the forge-specific token lifecycle and the dependence on commit-status badges entirely. Forge *actors* — a per-bottle account that comments on issues, opens PRs, and labels — can be a separate future PRD if they become necessary. The core value (signed, gate-enforced, host-attributed identity) stands on its own without them. ## Design ### Identity model Per **bottled agent** (agent definition ∘ sealed bottle), realized per activation: | Part | Value | Source | Role | |------|-------|--------|------| | Author/committer | name + email | `git-gate.user` (PRD 0027 overlay) | the claimed identity the gate **enforces** on every new commit | | Signing key | one Ed25519 keypair | minted host-side per activation | signs every commit; private half sidecar-only | The same author string and the same signing key serve every repo the bottle touches — one signing identity per bottled-agent activation. ### Manifest surface No new top-level keys and no `git-forge`/`forge-accounts` blocks (both dropped with the scope narrowing). The enforced identity **reuses the existing `git-gate.user`** (PRD 0027 name/email, agent-overlays-bottle semantics); a new opt-in `git-gate.signing` block turns on per-activation signing + gate enforcement. Keeping everything under `git-gate` also settles the earlier open question about where identity keys belong (PRD 0047 consolidation). ```yaml git-gate: user: # PRD 0027 — now the ENFORCED author/committer identity name: didericis-claude email: eric+claude@dideric.is signing: # NEW — opt-in per-activation signing + gate enforcement enabled: true enforce: [author, committer] # default; may be narrowed to [author] per bottle 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 into the feature. Without it, behavior is exactly as today (unsigned, unenforced). - `git-gate.signing.enforce` names which identity fields the gate matches against `git-gate.user`; it defaults to `[author, committer]` (both). - `git-gate.signing` is **bottle-only** (it carries enforcement policy, a home-only concern), 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 Unchanged from the previous revision, and 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-side acceptance check (the core of this PRD) The git-gate already fetches from upstream before every `upload-pack` and mirrors bidirectionally (PRD 0008). This PRD adds a **push-time acceptance gate** that runs after gitleaks and before the push is forwarded upstream, when `git-gate.signing.enabled` is set: 1. **Compute the newly-introduced set.** The 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 (e.g. human-authored ancestors); only commits the bottle actually introduced are checked. 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 (via a generated allowed-signers file — see Audit). A commit that is unsigned or signed by any other key is rejected. 3. **Verify identity fields.** For each field named in `signing.enforce` (default author **and** committer), the commit's name and email must equal `git-gate.user`. Any mismatch is rejected. 4. **Reject loudly on any failure**, naming the offending SHA and the reason (unsigned / wrong key / author mismatch / committer mismatch). The push does not reach the upstream. 5. **Record attribution.** For each accepted new commit, the host verifies the signature (step 2 is that verification) *before* writing/marking the SHA attributed in the audit store. The record is thus a cryptographic binding, not a bare assertion. Because the check is author **and** committer by default, a rebase/amend that rewrites committer to the bottle identity is fine, but a commit that keeps a foreign committer (or forges a foreign author) is rejected. ### Audit trail on `bottled_agent` The host SQLite store (PRD 0067, `~/.bot-bottle/bot-bottle.db`) records the signing-identity 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, author_name TEXT NOT NULL, author_email 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, 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. ### 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. ## Implementation chunks 1. **This PRD.** Sets the (narrowed) design. 2. **Manifest surface.** Add `git-gate.signing` (bottle-only; `enabled`, `enforce` with `[author, committer]` default); reuse `git-gate.user` as the enforced identity. Reject `signing` 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 acceptance check.** Compute the newly-introduced set (excluding upstream-reachable commits), verify signature against the activation key, verify author+committer against `git-gate.user`, reject loudly on any failure. Tests: unsigned rejected; wrong-key rejected; foreign author rejected; foreign committer rejected; pulled/merged upstream history passes; an all-conforming push succeeds. 5. **Audit + verification.** `bottled_agent_activation` / `attributed_commit` tables (PRD 0067 store) retaining full pubkey + fingerprint + principal + validity interval; record on accepted push after signature verification; allowed-signers generation + a `verify-commit` helper that works after teardown. 6. **Docs.** Glossary entry ("per-bottle signed identity"); README manifest section; ADR update noting that for signing-enabled bottles, authorship is *gate-enforced and host-attributed* (activation provenance), refining — not overturning — ADR 0002's "claimed, not vouched" posture. ## Testing strategy - **Unit (must):** `git-gate.signing` parse/validation matrix; agent-level `signing` rejection; `enforce` default and narrowing. - **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):** the rejection matrix above (unsigned / wrong key / foreign author / foreign committer), the upstream-reachable exclusion (pull + merge human history and push a conforming merge), and a clean all-conforming push. - **Lifecycle:** activation mints the signing key and writes an `active` row; teardown retires it (`valid_until` set) 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 - **`author` only vs `author` + `committer` enforcement.** Resolved to **both** (issue #423, comment #5556, owner preference); `enforce` defaults to `[author, committer]` and may be narrowed per bottle. Kept as a manifest knob in case a workflow needs author-only. - **Where the gate check observes new SHAs.** Modeled here as a push-time acceptance step after gitleaks (the gate already has both the pushed tips and the fetched upstream refs). Confirm this composes with the existing access-hook / mirror ordering in PRD 0008 rather than needing a separate hook. - **Validating signing broker (future).** If a future requirement wants the *signature itself* to vouch author/committer (not just the gate + host record), a broker in front of the key that parses the commit payload before signing would provide it. Out of scope; noted so the door stays open.