diff --git a/docs/prds/prd-new-signed-commits-attribution.md b/docs/prds/prd-new-signed-commits-attribution.md new file mode 100644 index 00000000..c98f0006 --- /dev/null +++ b/docs/prds/prd-new-signed-commits-attribution.md @@ -0,0 +1,420 @@ +# 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. diff --git a/docs/prds/prd-new-signed-identity-attribution.md b/docs/prds/prd-new-signed-identity-attribution.md deleted file mode 100644 index bfd8b567..00000000 --- a/docs/prds/prd-new-signed-identity-attribution.md +++ /dev/null @@ -1,400 +0,0 @@ -# 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.