From d8362ec6e9cea0d4fe058e388e097d998c9a9092 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 01:41:05 +0000 Subject: [PATCH] docs: narrow PRD to per-bottle signed identity & audit attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revised per PR #480 review (#5518 → #5556): - Rename: "forge subroles" → "per-bottle signed identity & audit attribution"; rename the file to match. - Reframe the guarantee as bottle/activation provenance, not cryptographically-vouched author identity. Author/committer name/email is a claim carried inside the signed object, made trustworthy by a git-gate acceptance check + the host record, not by the signature. - Add the gate-side acceptance check: on push, every newly-introduced commit (excluding upstream-reachable history) must verify against the activation key AND match git-gate.user in both author and committer fields, else the push is rejected. Host verifies the signature before recording a SHA as attributed. - Audit: retain full public key + fingerprint + principal + validity interval (not fingerprint-only); state allowed-signers generation. - Drop from scope: forge subuser accounts, provisioned API tokens/PAT minting, forge status/Verified badges -> future "forge actors" PRD. This removes the Gitea PAT bootstrap problem entirely. - Manifest: drop git-forge/forge-accounts; reuse git-gate.user as the enforced identity + add opt-in git-gate.signing. Push stays PRD 0048 deploy keys, unchanged. Issue: #423 Co-Authored-By: Claude Opus 4.8 --- docs/prds/prd-new-forge-subroles.md | 433 ------------------ .../prd-new-signed-identity-attribution.md | 400 ++++++++++++++++ 2 files changed, 400 insertions(+), 433 deletions(-) delete mode 100644 docs/prds/prd-new-forge-subroles.md create mode 100644 docs/prds/prd-new-signed-identity-attribution.md diff --git a/docs/prds/prd-new-forge-subroles.md b/docs/prds/prd-new-forge-subroles.md deleted file mode 100644 index 60d38513..00000000 --- a/docs/prds/prd-new-forge-subroles.md +++ /dev/null @@ -1,433 +0,0 @@ -# PRD prd-new: Forge subroles — per-bottle subuser identity & vouched commit attribution - -- **Status:** Draft -- **Author:** didericis-claude -- **Created:** 2026-07-25 -- **Issue:** #423 - -## Summary - -Give each bottled agent a **forge subrole**: a single, per-instance identity on -a git forge (Gitea today) with a distinct forge account, a strictly-scoped API -token, per-repo push credentials, and — the point of the whole thing — -**vouched** commit attribution via SSH commit signing performed in the git-gate -trust boundary, outside the bottle. Author name/email, forge account, and -signing key are one identity per bottled agent, reused across every repo and -every forge that bottle touches. All credential material is minted host-side at -spin-up, reaches the sidecar but never the bottle, and is revoked at teardown -(PRD 0048 discipline). Old key fingerprints and activation/deactivation cycles -are retained on the `bottled_agent` table as a durable audit trail. - -This is the "identity" successor to the two PRDs that set it up: - -- **PRD 0027 (agent git identity, #94)** established that `git-gate.user` - name/email is *claimed, not vouched* (ADR 0002) — forgeable, cosmetic, and - explicitly deferred the integrity question to "a commit-*signing* concern - (SSH/GPG)." This PRD is that signing concern; it makes authorship - cryptographically vouched and closes the door ADR 0002 left open. -- **PRD 0048 (deploy-key provisioning, #169)** established the host-side - provisioner pattern: a developer-held minting credential mints short-lived, - per-spin-up, revoked-at-teardown key material that reaches the sidecar but - never the bottle. Subrole credentials follow the same lifecycle. - -## Problem - -An agent runs on the developer's own machine and inherits the machine's trust, -scoped down per role — a *subrole*, not a new principal. That works locally -because the machine is single-tenant. A **git forge is the one shared, -multi-user layer** where a distinct identity actually earns its keep, and today -bot-bottle has none: - -- **Attribution is unvouched.** A bottle commits under whatever `git-gate.user` - name/email the manifest declares, which ADR 0002 accepts as forgeable and - cosmetic. On a shared forge there is no way to tell a real commit by the - subrole from a spoof, and no tamper-evidence on the history the agent - produced. -- **There is no independent identity to revoke.** Push happens under the - bottle's deploy key (0048), but there is no forge *account* for the subrole - whose access can be granted, scoped, and killed without touching the - developer's own account. -- **Forge API actions borrow the developer's hand.** Commenting on an issue, - opening a PR, or labeling is done — if at all — as the developer, not as a - scoped subrole with its own least-privilege token. - -The naming convention already in use — `didericis-claude`, `didericis-codex` -(prefix = lineage/ownership, suffix = subrole) — anticipates this. The identity -must be **keyed to the bottled agent** (the trust/credential boundary), not -derived from the provider template; today's provider-shaped names are just the -special case where the bottle happens to be one-provider. Nothing should -*derive* the forge username from the provider (the current code does not — only -the container image tag is provider-derived, which is correct). - -## Goals / Success Criteria - -- **One identity per bottled agent.** A single author name/email, a single - forge account per forge, and a single SSH signing key serve every repo and - every forge the bottle touches. The manifest cannot express two identities - for one bottled agent. -- **Vouched attribution.** Commits produced in the bottle carry a valid SSH - signature (`git verify-commit` succeeds against the retained public key) with - no commit-SHA divergence between agent-space and the signed upstream. -- **Private signing key never enters the bottle.** Only a bounded signing - capability (a forwarded `ssh-agent` socket) crosses the boundary. -- **Scoped, independently-revocable forge access.** The subrole is a strict - subset of the developer's forge access: a scoped collaborator/account with a - forge-scoped, least-privilege API token (commenting, issues, PRs, labeling) - and per-repo push credentials. -- **Reprovision-per-activation lifecycle.** Push credentials, API token, and - signing key are freshly minted on each activation and revoked at teardown; - failure to revoke halts teardown loudly (0048). This avoids credential - accumulation across frozen snapshots. -- **Durable audit trail.** Each activation/deactivation cycle and every retired - key is recorded on the `bottled_agent` table as a **public-key fingerprint - only** — never private key material. -- **Fail-closed provisioning.** A bottle that declares a forge (via - `git-forge` or a `git-gate.repos` entry) with no corresponding - `agent.forge-accounts` entry fails provisioning with a clear error rather - than launching with a half-configured identity. -- **Least-privilege, host-only minting credential.** The credential that mints - subrole material can create/delete keys and tokens for owned repos/accounts — - *not* act as instance admin — and never enters the bottle. - -## Non-goals - -- **Non-Gitea forges.** GitHub and GitLab both support SSH signing keys and - commit-status APIs and are a natural fit, but each is a future - `bot_bottle/contrib//` sub-package. This PRD ships Gitea only. -- **Forge-rendered "Verified" badges.** Deliberately abandoned — see Design. - Vouched-ness is carried by local `git verify-commit` plus a durable console - audit record and (optionally) a commit-status badge, not by the forge's - ephemeral signature UI. -- **Design B forge-visible pusher identity.** We keep repo-scoped deploy keys - for push *capability* (0048 unchanged) and get vouched *authorship* from - signing. Making the forge *pusher* record itself the subrole (subuser-owned - push keys, broader account/collaborator minting) is a larger surface deferred - until a forge-visible pusher is an actual requirement. -- **Dirty-teardown reconciliation.** We assume clean teardown for now. A - separate cleanup/sync pass reconciles orphaned forge credentials left by a - crash or a frozen-then-discarded snapshot; it is out of scope here. -- **Dashboard UI** for listing or revoking orphaned subrole credentials. -- **Rotation mid-session.** Credentials live for exactly one - activation→teardown cycle, as in 0048. -- **Generalizing the minting credential into the full `SecretProvider` - (#355).** This PRD consumes the existing provisioned-secret reference shape; - it does not build the general provider surface. - -## Design - -### Identity model - -An identity is realized per **bottled agent** (an agent definition composed -with its sealed bottle). It has three parts, all sharing one lifecycle: - -| Part | Value | Where declared | Scope | -|------|-------|----------------|-------| -| Author | name + email | agent file (`author`) | commit author string | -| Forge account | subuser per forge | agent file (`forge-accounts`) | API token owner, PR/issue/label actor | -| Signing key | one SSH (Ed25519) keypair | minted, not declared | signs every commit, all repos/forges | - -The same author string and the same signing key are used for **all** repos and -**all** forges the bottle touches — there is exactly one identity per bottled -agent. Which forge *account* to act as is per-forge (a bottle may touch more -than one forge), so `forge-accounts` is a map. - -### Manifest surface - -The identity splits across the two manifest files along the existing trust -boundary (ADR 0002, PRD 0047): **identity claims live in the agent file** -(overlayable, cosmetic-until-vouched), **forge connections and credential -material live in the bottle file** (home-only, credential-bearing). - -**Agent file** — the identity this bottled agent claims: - -```yaml -author: - name: didericis-claude - email: eric+claude@dideric.is -forge-accounts: - gitea: didericis-claude # forge name → subuser account -``` - -**Bottle file** — how to reach each forge, and which repos the gate exposes: - -```yaml -git-forge: - gitea: # forge name (referenced by repos + accounts) - type: gitea - ssh: - url: ssh://git@100.78.141.42:30009 - host_key: "ssh-ed25519 AAAA..." - api: - url: https://gitea.dideric.is/api/v1 - auth: - scheme: token - token_secret: - type: provisioned # the subrole's API token, minted per-activation - provisioner_secret: GITEA_ADMIN_TOKEN # host-only minting credential -git-gate: - repos: - bot-bottle: - type: forge-repo - forge: gitea # references git-forge.gitea - organization: didericis - identity_secret: - type: provisioned # per-repo push deploy key, minted per-activation (0048) -``` - -Notes on the shape: - -- `git-forge.` is a new bottle-only block describing one forge: its SSH - endpoint (for push, with a pinned `host_key`) and its API endpoint (for - subrole actions and commit-status). `provisioner_secret` names a **host env - var** holding the least-privilege minting credential; it is resolved at - provision time and never stored in the plan or seen by the bottle (same - discipline as `token_env` in 0048). -- `token_secret: { type: provisioned }` and `identity_secret: { type: - provisioned }` are secret *references*, not secret values. `provisioned` - means "bot-bottle mints this at spin-up." This is the same reference shape the - SecretProvider work (#355) generalizes; here we implement only the - `provisioned` case. A future `type: env` / `type: static` can slot in for - operator-supplied material, mirroring 0048's `identity:` escape hatch. -- `git-gate.repos.` moves from carrying a raw `url`/`identity` (PRD 0047) - to referencing a named forge plus `organization`; the gate derives the push - URL from `git-forge..ssh.url` + `organization` + repo name. This keeps - the forge endpoint declared once and reused. Operator-supplied static repos - (0048 `identity:` / 0047 `url:`) remain valid for repos that opt out of the - forge-subrole machinery. - -The `author`/`forge-accounts` keys are added to `AGENT_KEYS_OPTIONAL`; -`git-forge` is added to `BOTTLE_KEYS`. Whether `author`/`forge-accounts` should -nest under `git-gate:` (per 0047's "consolidate all git config under one key") -or stay top-level as sketched here is an open question below. - -### Provisioning validation (fail-closed) - -At seal/prepare time, before any container starts: - -1. Every forge referenced by a `git-gate.repos[*].forge` and every key in - `git-forge` **must** have a matching entry in the agent's `forge-accounts`. - A bottle that declares a forge without an agent account fails with: - - ``` - bottle 'dev' declares forge 'gitea' (git-forge / git-gate.repos['bot-bottle']) - but agent 'implementer' has no forge-accounts entry for it. A forge subrole - requires a forge account; add `forge-accounts: { gitea: }`. - ``` - -2. `author.name` and `author.email` must both be non-empty when any forge is - declared (a vouched identity needs an author string to vouch for). - -3. Each `provisioner_secret` env var must be present in the host environment; - absence fails loud rather than silently skipping provisioning. - -### Signing: sign at commit time via a forwarded ssh-agent - -The core constraint (issue #423, comment #4321): signing must happen **in the -git-gate trust boundary, not in the bottle**, yet the commit SHA must be final -at commit time so agent-space and the signed upstream never diverge. - -Chosen mechanism: - -- The **sidecar** (already the git-gate trust boundary, outside the bottle) - 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 private key never has a representation inside - the bottle's filesystem or memory. -- The bottle's `.gitconfig` (written by the provisioner) sets: - - ```ini - [commit] - gpgsign = true - [gpg] - format = ssh - [user] - signingkey = ssh-ed25519 AAAA... # the subrole signing PUBLIC key - ``` - -- `git commit` in the bottle asks the forwarded agent to sign; the signature is - embedded in the commit object at creation. The SHA the agent sees **is** the - SHA that reaches the upstream through the gate. No transcoding, no SHA - translation table, no divergence. - -`git verify-commit` succeeds anywhere that has the subrole public key, which is -retained in the audit record (below). - -### Attribution surface: no forge "Verified" badge - -Forge-rendered "Verified" badges are **intentionally abandoned**. Two reasons, -both established in the issue thread: - -1. **They are ephemeral.** Gitea (and the others) render the badge dynamically - at display time by matching the commit signature against a *currently - registered* signing key. Because subrole signing keys are reprovisioned and - revoked every activation cycle, a forge-registered key would show - "unverified" as soon as the bottle tears down — the badge would lie about - history the moment the session ends. -2. **We don't want to register signing keys on the forge at all.** Registering - a signing key on Gitea also grants push access (Gitea does not separate - signing keys from access keys), coupling a presentation concern to a - capability. Push is already handled by scoped deploy keys (0048); the - signing key should carry no forge access. - -Instead, vouched-ness is surfaced two durable ways: - -- **Local verification.** `git verify-commit ` against the retained public - key is the ground truth and survives independent of any forge. -- **Commit-status badge (optional, per-forge).** The orchestrator/console posts - a commit *status* via the forge API (all three forges expose this and it - persists server-side permanently): `pending` when the commit is observed, - `success` at clean teardown, with a `target_url` pointing at the durable - console audit record. This is posted using the subrole's API token, so the - badge is attributed to the subrole, and it does not depend on any key still - being registered. - -### Credential lifecycle - -Follows PRD 0048's mint-at-spin-up / revoke-at-teardown discipline, extended to -three credential kinds and made **reprovision-per-activation**: - -**At activation (spin-up), host-side, using each forge's `provisioner_secret`:** - -1. Mint a fresh Ed25519 **signing** keypair. Load the private half into the - sidecar `ssh-agent`; write the public half into the bottle `.gitconfig` - `user.signingkey` and into the audit record (fingerprint). -2. Mint a fresh **API token** for the subrole account, forge-scoped to the - minimum: commenting, creating issues, creating PRs, labeling issues. - Resolves the `token_secret: { type: provisioned }` reference. Held host/ - sidecar-side for commit-status and forge actions; never handed to the - bottle raw (the bottle reaches the forge API through the gate/broker). -3. Mint fresh per-repo **push deploy keys** for every `git-gate.repos` entry - whose `identity_secret` is `provisioned` — exactly PRD 0048's - `DeployKeyProvisioner.create`. - -Keys are **generated once per activation and persist across restarts** within -that activation (a restart re-attaches the same minted material, it does not -re-mint). Re-minting happens on a *new* activation. This is deliberate: minting -per-restart would pile up dead keys on the forge for every frozen snapshot; -minting per-activation keeps exactly one live key set per running bottled agent. - -**At teardown (fail-loud, 0048):** - -- Delete the API token and every provisioned deploy key via the forge API. -- Discard the signing key from the sidecar `ssh-agent` (it was never on the - forge, so there is nothing to revoke there — but its fingerprint is retired - in the audit trail). -- Any deletion failure **halts teardown and propagates loudly** (404 = - already-gone = success, per 0048). -- We **assume clean teardown.** Dirty teardown (crash, discarded frozen - snapshot) may orphan forge credentials; a separate cleanup/sync pass - reconciles those and is out of scope here. - -### Audit trail on `bottled_agent` - -The host SQLite store (PRD 0067, `~/.bot-bottle/bot-bottle.db`) gains a -`bottled_agent`-scoped record of the identity lifecycle. Every activation and -deactivation is recorded, and **retired** keys are kept so history stays -attributable after rotation. Only **public-key fingerprints** are ever stored — -never private key material, never the API token value. - -Illustrative shape (subject to alignment with the forge-orchestration tables -0067 anticipated): - -```sql -CREATE TABLE bottled_agent_identity ( - bottled_agent_slug TEXT NOT NULL, - activation_id TEXT NOT NULL, -- one per activation cycle - forge TEXT NOT NULL, -- e.g. 'gitea' - forge_account TEXT NOT NULL, -- e.g. 'didericis-claude' - author_name TEXT NOT NULL, - author_email TEXT NOT NULL, - signing_key_fpr TEXT NOT NULL, -- SHA256:... public-key fingerprint - activated_at TEXT NOT NULL, - deactivated_at TEXT, -- NULL while active - status TEXT NOT NULL, -- active | retired - PRIMARY KEY (bottled_agent_slug, activation_id, forge) -); -``` - -This table is the `target_url` destination for commit-status badges and the -source of truth for "which key signed this commit, and when was that identity -live." - -## Alternatives considered - -- **Stateless reversible transcoder (issue #423, comment #4321, Solution 2).** - Strip/re-apply the `gpgsig` header on fetch/push with deterministic signing - and an in-memory SHA translation table. Rejected: it introduces a SHA - divergence between agent-space and remote-space that must be maintained for - the life of the session, with no persistence story across restarts. - Sign-at-commit-time keeps SHAs identical and needs no translation state. -- **Register the signing key on the forge for a "Verified" badge (original - issue sketch).** Rejected: badges are rendered dynamically and vanish when - the reprovisioned key is revoked; on Gitea a signing key also grants push, - coupling presentation to capability. See "Attribution surface" above. -- **Design B: subuser-owned push keys / forge pusher = subrole.** Deferred; - larger account- and collaborator-management surface with a broader minting - token, not required to get vouched authorship. - -## Implementation chunks - -1. **This PRD.** Sets the design. -2. **Manifest surface + validation.** Add `author` / `forge-accounts` to agent - keys; add `git-forge` to bottle keys; parse the provisioned-secret reference - shape; restructure `git-gate.repos` to reference a named forge + - organization while keeping 0047/0048 static entries valid. Fail-closed - checks (forge-without-account, missing author, missing `provisioner_secret`). - Unit tests for each parse/validation path. -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 - `git verify-commit`-valid and its SHA is unchanged through the gate. -4. **Forge-account provisioning (Gitea contrib).** Extend - `bot_bottle/contrib/gitea/` with API-token minting (scoped: comment, issue, - PR, label) and revocation, reusing the `provisioner_secret` custody model. - Wire the signing-key and API-token lifecycle into the same - activation/teardown hooks as `DeployKeyProvisioner`. -5. **Audit + commit-status.** `bottled_agent_identity` table (PRD 0067 store); - record activation/deactivation and retired-key fingerprints; post - commit-status badges (`pending` → `success`) with `target_url` to the audit - record. -6. **Docs.** Glossary entry for "Forge Subrole"; README manifest section; - ADR update noting authorship is now *vouched* for signed bottled agents - (superseding the ADR 0002 posture for this path). - -## Testing strategy - -- **Unit (must):** manifest parse/validation matrix — `author` + - `forge-accounts` on the agent; `git-forge` on the bottle; provisioned-secret - references; the fail-closed cases (forge without account, empty author, - absent `provisioner_secret`); `git-gate.repos` forge-reference resolution - alongside surviving 0047/0048 static entries. -- **Integration (must):** end-to-end signed commit — bottle commits, the - signature verifies with `git verify-commit`, and the SHA observed in the - bottle equals the SHA that lands upstream through the gate. Private key is - absent from the bottle filesystem/agent-visible memory. -- **Lifecycle (must):** activation mints signing key + API token + deploy keys; - teardown revokes all three and halts loudly on a forced API failure; the - `bottled_agent_identity` row transitions `active` → `retired` with only a - fingerprint stored. -- **Reprovision-per-activation:** a restart re-attaches the same key set (no new - forge key); a fresh activation mints a new set and retires the old - fingerprint. - -## Open questions - -- **Key placement in the manifest.** Do `author` / `forge-accounts` stay - top-level on the agent file (as sketched by the owner in #4927), or nest under - `git-gate:` to honor PRD 0047's "consolidate all git configuration under one - key"? The former reads cleaner; the latter is more consistent. Resolve before - chunk 2. -- **Where does the commit-status poster run?** The orchestrator/console holds - the API token host-side and is the natural poster, but it must observe new - commit SHAs. Is that a gate push-hook callback, or a poll? (Ties into the - forge-orchestration work 0067 anticipated.) -- **Provisioner-secret naming.** `GITEA_ADMIN_TOKEN` reads as instance-admin, - but the constraint is least-privilege (create/delete keys + tokens for owned - repos/accounts, *not* admin). Rename to `GITEA_MINT_TOKEN` (per the issue - body) to avoid implying more privilege than the credential should carry? -- **Signing-key fingerprint vs. full pubkey in the audit record.** The thread - settled on "public-key fingerprint" for the audit trail, but `git - verify-commit` needs the full public key. Store both (full pubkey for - verification, fingerprint as the stable handle), or reconstruct verification - material elsewhere? diff --git a/docs/prds/prd-new-signed-identity-attribution.md b/docs/prds/prd-new-signed-identity-attribution.md new file mode 100644 index 00000000..bfd8b567 --- /dev/null +++ b/docs/prds/prd-new-signed-identity-attribution.md @@ -0,0 +1,400 @@ +# 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.