From 34bb7263fa5f4d954f2b1d4bb7bc04f974bd4e2b Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 22:53:14 +0000 Subject: [PATCH 1/7] docs: PRD for forge subroles (per-bottle subuser identity & vouched attribution) Formalizes the design settled in issue #423: one forge subrole identity per bottled agent (author + forge account + SSH signing key), reused across all repos/forges. Vouched attribution via sign-at-commit-time in the git-gate boundary (forwarded ssh-agent; private key never in the bottle; no SHA divergence). Forge "Verified" badges abandoned in favor of local git verify-commit plus durable console audit records and commit-status badges. Reprovision-per-activation credential lifecycle (0048 discipline), fail-loud teardown, public-key-fingerprint-only audit trail on bottled_agent. Successor to PRD 0027 (claimed-not-vouched, ADR 0002) and PRD 0048 (host-side minting lifecycle). Issue: #423 Co-Authored-By: Claude Opus 4.8 --- docs/prds/prd-new-forge-subroles.md | 433 ++++++++++++++++++++++++++++ 1 file changed, 433 insertions(+) create mode 100644 docs/prds/prd-new-forge-subroles.md diff --git a/docs/prds/prd-new-forge-subroles.md b/docs/prds/prd-new-forge-subroles.md new file mode 100644 index 00000000..60d38513 --- /dev/null +++ b/docs/prds/prd-new-forge-subroles.md @@ -0,0 +1,433 @@ +# 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? -- 2.52.0 From c62d57d5acb234021ba480f3db0bd84cc470ee57 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 01:41:05 +0000 Subject: [PATCH 2/7] 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. -- 2.52.0 From 245f258f206ecb10e539d806fb1fc2c2590eb7c3 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 01:57:45 +0000 Subject: [PATCH 3/7] docs: drop author/committer enforcement; anchor audit in control plane Revised per PR #480 review (#5590 + didericis-codex review on d8362ec): - Remove author/committer enforcement entirely (#5590). The gate no longer matches identity fields; author/committer are recorded as claims in the audit store. Drop the git-gate.signing.enforce knob (which also resolves codex issue 1: a knob that weakened the stated guarantee). Add a "Deferred: identity enforcement" section noting it as a possible future add. Rename PRD/file to "signed commits & audit attribution" since identity is no longer guaranteed. - Fix control-plane vs data-plane verification (codex issue 2, PRD 0070): git-gate (data plane) does a synchronous pre-forward SIGNATURE check only; the orchestrator/control plane (sole owner of bot-bottle.db) independently re-verifies each signature before writing attributed_commit. A gateway assertion alone never creates an audit row. New "Trust boundary" + "Control-plane verification & recording" sections. - Reframe the guarantee to signed provenance + host-owned, independently verified audit record; ADR 0002 "claimed, not vouched" posture kept. - attributed_commit now records claimed author/committer columns. Issue: #423 Co-Authored-By: Claude Opus 4.8 --- .../prd-new-signed-commits-attribution.md | 420 ++++++++++++++++++ .../prd-new-signed-identity-attribution.md | 400 ----------------- 2 files changed, 420 insertions(+), 400 deletions(-) create mode 100644 docs/prds/prd-new-signed-commits-attribution.md delete mode 100644 docs/prds/prd-new-signed-identity-attribution.md 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. -- 2.52.0 From 010e253d66fb6faef5596eb06461a8ccadf36f51 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 26 Jul 2026 02:22:35 +0000 Subject: [PATCH 4/7] docs: sharpen the attribution guarantee to a byte<->activation-key binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revised per PR #480 (#5607 owner clarification + #5608 codex resolution; #5612 directs the update): - The audit row no longer implies upstream observation or agent-only authorship. Reworded the guarantee: the row cryptographically binds commit bytes (control-plane-RECOMPUTED SHA) to access to the activation signing key, and binds that key to control-plane-owned activation metadata. An agent can sign arbitrary contents but cannot verify as a different activation or choose the recorded metadata. - Control plane accepts gateway-delivered opaque bytes, independently recomputes the Git object ID, verifies the embedded signature against the activation key, and stamps its own metadata. Trusts no gateway SHA/key/verdict/metadata. No upstream fetch. - Purged overclaims: removed "a compromised gateway cannot fabricate an audit binding" (the sidecar holds the signing capability, so it can — and that's acceptable under the intended guarantee), plus "accepted push" / "introduced upstream" framing. - Resolved the control-plane-transport open question in-PRD (was left open; codex asked to resolve): transport is gateway bytes + recompute + verify; mirror-read is no stronger. Issue: #423 Co-Authored-By: Claude Opus 4.8 --- .../prd-new-signed-commits-attribution.md | 177 +++++++++++------- 1 file changed, 110 insertions(+), 67 deletions(-) diff --git a/docs/prds/prd-new-signed-commits-attribution.md b/docs/prds/prd-new-signed-commits-attribution.md index c98f0006..108dad00 100644 --- a/docs/prds/prd-new-signed-commits-attribution.md +++ b/docs/prds/prd-new-signed-commits-attribution.md @@ -8,15 +8,18 @@ ## 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 +produces is signed in the git-gate trust boundary (outside the bottle) and recorded in bot-bottle's own **host-owned audit store**, which is the portable -source of truth 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. +source of truth. Each row cryptographically binds a commit's bytes (and their +control-plane-recomputed SHA) to **access to that activation's signing key**, and +binds the key to control-plane-owned activation metadata — bottle, host, +manifest, agent, activation interval, retained public key — plus the commit's +*claimed* author. The gate mints a short-lived Ed25519 key at spin-up, holds the +private half in the sidecar `ssh-agent`, and forwards only `SSH_AUTH_SOCK` into +the bottle. The gate rejects any commit it forwards that is not signed by the +activation key; separately, the **control plane** independently recomputes each +commit's object ID and verifies its signature before recording attribution — it +never trusts a SHA, key, or verdict asserted by the gate. This PRD deliberately does **not** enforce or vouch author/committer identity. Author/committer name/email are recorded as claims carried inside the signed @@ -43,26 +46,38 @@ Successor to: 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. +> The **host-owned audit store** binds a set of commit bytes — whose Git object +> ID the control plane **recomputes** itself — to **access to this activation's +> signing key**, and binds that key to **control-plane-owned activation +> metadata**: bottle, host, manifest, agent, activation interval, retained public +> key. An agent may author and sign arbitrary commit contents, but it cannot make +> that signature verify as a *different* activation, and it cannot choose the +> activation metadata the control plane records. The commit's author/committer +> identity is **recorded as a claim**, not enforced or vouched. The forge remains +> only the repository transport/capability layer. -What the signature does and does not prove (issue #423, comment #5554): +What this does and does not prove (issue #423, comments #5554 / #5607 / #5608): -- 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**). +- It proves **access to activation *Y*'s signing key**: whoever assembled these + commit bytes could sign with that key. Recomputing the object ID and verifying + the embedded signature binds the SHA to activation *Y*, and the control plane's + own records bind *Y*'s key to *Y*'s metadata. +- It does **not** prove the commit was ever pushed, observed upstream, kept + (vs. later reverted or dropped), or produced by the *agent* rather than by any + other holder of the activation signing capability (the sidecar itself). The + store deliberately makes no claim about publication or sole-agent authorship + — the owner's requirement is attribution of *what manifest/agent/etc. was in + use when a commit was signed*, not proof of where the commit went (#5607). +- It does **not** make author/committer identity cryptographically vouched. The + bottle chooses every byte sent through the forwarded agent, so a signature over + `author Mallory ` is just as valid. Those fields are a claim + carried inside the signed object and recorded as-is. +- The binding is trustworthy because the **control plane** supplies the SHA (it + recomputes it), the public key, and the activation metadata from its own state + — never from a value the gateway asserts (see **Trust boundary**). The + residual, by design: anything that holds the activation signing capability can + produce commits that attribute to that activation. That is inherent to a + binding on *activation-key access*, not a defect. ## Problem @@ -94,10 +109,12 @@ tamper-evidence over it: 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 + owner of `bot-bottle.db`, PRD 0070) recomputes each commit's object ID from the + bytes, verifies the embedded signature against the activation public key it + holds, and attaches activation metadata from its own state — accepting no SHA, + key, verdict, or metadata asserted by the gateway. No upstream fetch is + required. +- **Host is the source of truth.** The audit record binds each recomputed SHA to the bottle, host, manifest, agent, activation interval, and retained public key, and records the commit's claimed author/committer. - **Verifiable after teardown.** The audit record retains the **full public @@ -166,18 +183,30 @@ and forwards pushes. The orchestrator is the **control plane** and, per PRD 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. + can reject a push before it reaches the upstream. This is a data-plane gate on + what leaves the bottle, not the audit binding. +- The **control plane** takes the commit bytes to attribute (gateway-delivered + opaque bytes are fine), **recomputes the Git object ID**, **verifies the + embedded signature** against the activation public key it minted and holds, and + writes `attributed_commit` attaching metadata from its own state. It accepts + **no** gateway-supplied `verified` flag, claimed SHA, public key, or activation + identity. -The gate cannot stand in for host-side audit verification (issue #423, review by -didericis-codex on d8362ec). +The precise trust statement (issue #423, review by didericis-codex on d8362ec, +resolved in #5608): the row binds *these commit bytes / this recomputed SHA* to +*access to this activation's signing key*, and the control plane binds that key +to the recorded activation metadata. It does **not** assert forge observation or +that only the agent (not the signing sidecar) authored the commit — so this PRD +does **not** claim a compromised gateway cannot obtain an attribution row. +Because the sidecar holds the activation signing capability, a compromised +gateway *can* assemble and sign a commit and have it attributed to that +activation; what it cannot do is make the signature verify as a *different* +activation or choose the metadata the control plane records. That residual is +acceptable under the intended guarantee (#5607) and is why the guarantee is +worded as activation-key access, not agent-only authorship or upstream +publication. The gate therefore cannot stand in for host-side verification: the +control plane recomputes the object ID and verifies the signature itself rather +than trusting the gate's word. ### Identity model @@ -266,20 +295,25 @@ record. ### Control-plane verification & recording -On an accepted push (or asynchronously, driven by the accepted-push event), the -orchestrator/control plane: +For each commit to attribute (the gate hands the control plane the commit bytes; +opaque gateway-delivered bytes are acceptable because nothing the gateway *says* +about them is trusted), the orchestrator/control plane: -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. +1. **Recomputes the Git object ID** from the bytes itself. The stored `sha` is + this recomputed value, never a SHA the gateway claims. +2. **Verifies the embedded signature** against the activation public key it + minted and holds for that activation (via a generated allowed-signers file) — + ignoring any `verified` flag, key, or activation identity supplied by the + gateway. +3. Writes `attributed_commit` only for bytes that pass, stamping the activation + metadata (bottle/manifest/agent/host/interval) from its **own** state — not + from anything the gateway provides — and recording the commit's claimed + author/committer. -A SHA the control plane cannot independently verify is **not** recorded as -attributed (it may be logged as an anomaly instead). +No upstream fetch is required: the guarantee is a byte↔activation-key binding, so +the object does not need to come from the forge (issue #423, #5608). Bytes that +do not verify against the activation key are **not** recorded as attributed (they +may be logged as an anomaly instead). ### Audit trail @@ -307,7 +341,7 @@ CREATE TABLE bottled_agent_activation ( ); CREATE TABLE attributed_commit ( - sha TEXT NOT NULL, + sha TEXT NOT NULL, -- control-plane-RECOMPUTED object ID, not gateway-claimed bottled_agent_slug TEXT NOT NULL, activation_id TEXT NOT NULL, repo TEXT NOT NULL, @@ -378,11 +412,12 @@ validating signing broker in front of the key would be required for the latter. **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. + the control plane recomputes each commit's object ID and verifies the + signature before writing a row; retain full pubkey + fingerprint + principal + + validity interval; record claimed author/committer; allowed-signers generation + + a post-teardown `verify-commit` helper. Tests: a gateway-claimed SHA/key/ + verdict is ignored — the row's `sha` is the recomputed ID and bytes not signed + by the activation key produce **no** 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). @@ -398,9 +433,10 @@ validating signing broker in front of the key would be required for the latter. 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. +- **Control plane (must):** the control plane recomputes the object ID and + records a row for bytes genuinely signed by the activation key; a gateway- + supplied SHA/key/verdict is ignored (the stored `sha` is the recomputed value); + bytes signed by a foreign/invalid key produce **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 @@ -408,13 +444,20 @@ validating signing broker in front of the key would be required for the latter. - **Post-teardown verification:** regenerate the allowed-signers file from a `retired` row and confirm `verify-commit` still succeeds for an attributed SHA. +## Resolved: control-plane transport + +Raised in review and **resolved** (issue #423, #5608): the commit object does not +need to come from the forge, and reading the gateway-owned mirror is no stronger +than accepting gateway-delivered bytes — both are fabricatable, and neither +matters because the control plane trusts nothing the gateway *asserts*. The +transport is therefore: the gate hands the control plane the raw commit bytes, +the control plane **recomputes the object ID** and **verifies the signature** +against the activation key, and stamps its own activation metadata. No upstream +fetch. This is exactly what makes the byte↔activation-key binding sound +regardless of transport. + ## Open questions -- **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. -- 2.52.0 From 9c19ce4c58d1f65ebfd1872a62e647d6c50e2fa9 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 18:32:07 +0000 Subject: [PATCH 5/7] docs: redesign forge identity trust boundary --- .../prd-new-signed-commits-attribution.md | 344 ++++++++++++++---- 1 file changed, 266 insertions(+), 78 deletions(-) diff --git a/docs/prds/prd-new-signed-commits-attribution.md b/docs/prds/prd-new-signed-commits-attribution.md index 108dad00..37fd0585 100644 --- a/docs/prds/prd-new-signed-commits-attribution.md +++ b/docs/prds/prd-new-signed-commits-attribution.md @@ -1,4 +1,4 @@ -# PRD prd-new: Per-bottle signed commits & audit attribution +# PRD prd-new: Trusted agent forge identity, signed commits & audit attribution - **Status:** Draft - **Author:** didericis-claude @@ -7,34 +7,47 @@ ## Summary -Give each bottled agent a **per-activation signing key** so that every commit it -produces is signed in the git-gate trust boundary (outside the bottle) and -recorded in bot-bottle's own **host-owned audit store**, which is the portable -source of truth. Each row cryptographically binds a commit's bytes (and their -control-plane-recomputed SHA) to **access to that activation's signing key**, and -binds the key to control-plane-owned activation metadata — bottle, host, -manifest, agent, activation interval, retained public key — plus the commit's -*claimed* author. The gate mints a short-lived Ed25519 key at spin-up, holds the +Give each **host-trusted agent definition** an author identity and named forge +accounts, and let a bottle associate each git-gate repository with one of those +accounts. The resolved association provisions authenticated forge API access +through the egress proxy and injects provider-specific workflow instructions +into the agent's system prompt without exposing the token. Repository-local +agent and bottle definitions are no longer discovered because they would +otherwise be able to select host credential references or impersonate an agent. + +For bottles that opt into signing, give each bottled agent a **per-activation +signing key** so every commit it produces is signed in the git-gate trust +boundary (outside the bottle) and recorded in bot-bottle's **host-owned audit +store**, which is the portable source of truth. Each row cryptographically binds +a commit's bytes (and their control-plane-recomputed SHA) to **access to that +activation's signing key**, and binds the key to control-plane-owned activation +metadata — bottle, host, manifest, agent, activation interval, retained public +key, configured agent author — and separately records the commit's *claimed* +author/committer. The gate mints a short-lived Ed25519 key at spin-up, holds the private half in the sidecar `ssh-agent`, and forwards only `SSH_AUTH_SOCK` into the bottle. The gate rejects any commit it forwards that is not signed by the activation key; separately, the **control plane** independently recomputes each commit's object ID and verifies its signature before recording attribution — it never trusts a SHA, key, or verdict asserted by the gate. -This PRD deliberately does **not** enforce or vouch author/committer identity. -Author/committer name/email are recorded as claims carried inside the signed -object; making the gate reject a mismatching author/committer is a possible -future add (see **Non-goals** and **Deferred: identity enforcement**). Push -capability stays exactly as PRD 0048 deploy keys; forge subuser accounts, -provisioned API tokens, and forge-side status/"Verified" badges remain out of -scope (a future "forge actors" PRD). +This PRD deliberately does **not** enforce or cryptographically vouch +author/committer identity. The trusted agent definition supplies the configured +name/email, while the values in each commit remain claims carried inside the +signed object; making the gate reject a mismatch is a possible future add (see +**Non-goals** and **Deferred: identity enforcement**). Git push capability stays +exactly as PRD 0048 deploy keys. Forge API identity uses an operator-provided, +agent-specific token referenced from the trusted host environment; bot-bottle +does not mint forge users or tokens. 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. + agent name/email is *claimed, not vouched*. This PRD moves that identity from + the bottle/git-gate overlay to the trusted agent definition and adds signed + **provenance** plus a durable host record, not identity enforcement. +- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to + override home agents. This PRD removes that trust path: agents and bottles are + loaded only from the host-owned `~/.bot-bottle` tree. - **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. @@ -50,11 +63,12 @@ The crisp property this feature provides: > ID the control plane **recomputes** itself — to **access to this activation's > signing key**, and binds that key to **control-plane-owned activation > metadata**: bottle, host, manifest, agent, activation interval, retained public -> key. An agent may author and sign arbitrary commit contents, but it cannot make -> that signature verify as a *different* activation, and it cannot choose the -> activation metadata the control plane records. The commit's author/committer -> identity is **recorded as a claim**, not enforced or vouched. The forge remains -> only the repository transport/capability layer. +> key, configured agent author. An agent may author and sign arbitrary commit +> contents, but it cannot make that signature verify as a *different* +> activation, and it cannot choose the activation metadata the control plane +> records. The commit's author/committer identity is **recorded separately as a +> claim**, not enforced or vouched. The forge is an external transport and +> collaboration surface, not the source of attribution truth. What this does and does not prove (issue #423, comments #5554 / #5607 / #5608): @@ -88,18 +102,49 @@ 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 + actually produced it. The configured 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. +- **Forge workflow context is missing.** The agent prompt does not know which + forge backs a git-gate repository, which API base URL to use, or that + authenticated requests must go through the egress proxy. Bespoke prompt text + has drifted between agents, causing incorrect PR creation behavior such as + using Gitea AGit review refs instead of a branch-backed pull request. +- **Identity is owned by the wrong layer.** `git-gate.user` puts an agent + property on a repository transport component. The same author identity and + forge actor should follow the agent across bottles and repositories. +- **Repository-local agents are a trust escalation.** Today + `$CWD/.bot-bottle/agents/*.md` can override a host agent. Once an agent + definition may reference a forge token, allowing the checked-out repository + to choose that definition would let untrusted workspace content select host + identities and credentials. ## 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. +- **Agent-owned identity.** Author name/email and named forge accounts live on + the agent definition, not under `git-gate`. +- **Bottle-owned repository policy.** Signing remains an opt-in property of the + bottle, and each bottle repository may associate itself with one named forge + account from the selected agent. +- **Forge-aware prompting.** The resolved agent+bottle manifest contributes a + generated, provider-specific system-prompt section describing the forge API + URL, proxy-authenticated access path, repository mapping, and safe PR + workflow. No token value or token environment-variable name appears in the + prompt. +- **Proxy-held forge credential.** The host resolves the forge account's token + reference and gives it only to the egress proxy, which injects authentication + for the configured forge origin. The bottle receives neither the token nor a + credential file containing it. +- **Trusted definitions only.** Agent and bottle files are discovered only + under the host-owned `~/.bot-bottle/{agents,bottles}` directories. + `$CWD/.bot-bottle/{agents,bottles}` never contributes definitions or + overrides. - **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. @@ -115,8 +160,9 @@ tamper-evidence over it: key, verdict, or metadata asserted by the gateway. No upstream fetch is required. - **Host is the source of truth.** The audit record binds each recomputed SHA to - the bottle, host, manifest, agent, activation interval, and retained public - key, and records the commit's claimed author/committer. + the bottle, host, manifest, agent, configured agent author, activation + interval, and retained public key, and separately 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 @@ -125,8 +171,9 @@ tamper-evidence over it: 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. +- **Push capability unchanged.** Git transport remains PRD 0048 deploy keys. + The forge account token is for forge API actions such as opening and + commenting on pull requests; it is not used for Git push. ## Non-goals @@ -137,12 +184,9 @@ tamper-evidence over it: **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 account or token minting.** Bot-bottle does not create subusers or + PATs. The operator creates the agent-specific account/token out of band and + names the host environment secret in the trusted agent definition. - **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 @@ -153,25 +197,34 @@ tamper-evidence over it: dirty-teardown reconciliation.** As before; a separate cleanup/sync pass handles orphans left by a crash or discarded snapshot. -## Scope narrowing +## Scope evolution 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: +tokens + optional forge status posting + signing). Review first narrowed it, +then restored only the part needed for correct agent operation: -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. +1. **Dropped forge account/token provisioning** (#5518 → #5556): the PAT + bootstrap is not implementable as sketched (Basic-Auth-as-target-user + constraint), and the signature never vouched the author anyway. The host + audit store remains the portable source of truth. 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. +3. **Restored declarative forge accounts, not provisioning** (#6002): agents + still need an operator-supplied API identity and forge-specific system + instructions to open and update PRs correctly. The trusted agent definition + therefore references an existing host secret; bot-bottle neither creates nor + rotates that credential. +4. **Moved identity to the agent trust domain** (#6002): author and forge + accounts are agent properties. Bottle repositories select an account by + name, while git-gate remains transport-only. Because these references grant + access to host credentials, repository-local agent/bottle discovery is + removed. -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. +The resulting feature is **trusted agent identity + operator-provided forge API +access + forge-aware prompting + signed commits + a host-owned, +independently-verified audit record.** Identity enforcement (the gate rejecting +a foreign author/committer) remains a candidate future PRD. ## Design @@ -216,19 +269,34 @@ 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 | +| Author/committer | name + email | trusted agent definition `author` | written into commits and **recorded** as a claim; **not** enforced | +| Forge actor | named account + API origin + token reference | trusted agent definition `forge-accounts` | authenticates forge API actions through the egress proxy | +| Repository/forge association | forge account name | bottle `git-gate.repos..forge` | selects the actor and generated workflow guidance for that repository | ### 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. +Identity belongs to the agent. Repository capability and policy belong to the +bottle. The following files are both host-owned: ```yaml +# ~/.bot-bottle/agents/claude.md +--- +author: + name: didericis-claude + email: eric+claude@dideric.is +forge-accounts: + didericis-gitea: + auth: + type: token + token_secret: GITEA_CLAUDE_TOKEN + url: https://gitea.dideric.is/api/v1 +--- +``` + +```yaml +# ~/.bot-bottle/bottles/dev.md +--- 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: @@ -238,15 +306,100 @@ git-gate: provider: gitea token_env: GITEA_DEPLOY_TOKEN host_key: "ssh-ed25519 AAAA..." + forge: didericis-gitea # account from the selected agent definition +--- ``` +- `author` is agent-only and replaces the `git-gate.user` agent/bottle overlay. + It is required when `git-gate.signing.enabled` is true or when any selected + repository has a `forge` association. The resolved values populate + `user.name` and `user.email`. +- `forge-accounts` is an agent-only map keyed by the existing manifest + kebab-case identifier grammar (`[a-z][a-z0-9-]*`). Each entry contains: + - `url`: an HTTPS forge API base URL. This PRD supports Gitea API URLs; a + future provider must add explicit typed validation and prompt generation + rather than accepting arbitrary prompt text supplied by a repository. + - `auth.type`: `token` in this slice. + - `auth.token_secret`: the name of a host environment variable containing the + operator-provided, agent-specific API token. The value is resolved only by + host provisioning and passed only to the egress proxy. +- `git-gate.repos..forge` is bottle-only and must resolve to an account in + the selected agent definition. An unknown account, an unsupported forge URL, + a non-HTTPS URL, or a missing/empty host secret fails launch before creating + the bottle. - `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. +- `git-gate.signing`, `git-gate.repos`, and their `forge` associations are + bottle-only. `author` and `forge-accounts` are agent-only. Validation errors + point to the correct file/type instead of silently ignoring misplaced keys. +- `provisioned_key.token_env` remains the deploy-key administration credential + from PRD 0048. It is separate from the forge actor's `token_secret`: the + former provisions Git push capability, while the latter performs API actions + as the agent. +- Existing `git-gate.user` fields fail with migration guidance to move the + values into the selected home agent's `author` block. There is no period where + bottle identity silently overrides agent identity. + +### Definition trust and discovery + +Only the host-owned manifest tree is authoritative: + +- Agents: `~/.bot-bottle/agents/*.md` +- Bottles: `~/.bot-bottle/bottles/*.md` + +`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer +overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If +either repository-local directory contains manifest files, bot-bottle emits a +warning that they are ignored and points to the home paths. Agent enumeration, +`require_agent`, lazy loading, and dashboard selectors all use the same +home-only index so there is no alternate path that can still select a workspace +definition. + +This intentionally supersedes PRD 0011's repository-agent overlay. Workspace +instructions remain repository content (for example `AGENTS.md`), but executable +runtime policy, host secret references, and actor identity do not. + +Programmatic in-memory manifests remain available for tests and internal +composition; they are already supplied by trusted host code and are not a +filesystem discovery path. + +### Forge API provisioning and generated prompt + +For each distinct forge account referenced by the selected bottle's repos, the +host: + +1. Parses and canonicalizes the HTTPS API origin and rejects credentials in the + URL, fragments, and unsupported path shapes. +2. Resolves `auth.token_secret` from the host environment. The secret value is + copied only into the egress proxy's credential environment. +3. Adds an inspected egress route scoped to that forge origin/API prefix with + the provider's authentication scheme (`token` for Gitea). Authentication is + injected by the proxy; the bottle sends an unauthenticated request to the + configured URL. +4. Appends a generated, non-secret section to bot-bottle's existing system + prompt file. The section is derived from validated typed fields, not copied + Markdown from a repository. + +For the example above, the generated guidance communicates: + +- account alias `didericis-gitea` and API base + `https://gitea.dideric.is/api/v1`; +- repository `bot-bottle` uses that account; +- forge API calls must use the configured HTTPS URL through the proxy and must + not read, print, or manually attach an authorization token; +- Git pushes still use the bottle's git-gate remote; +- create/update a normal `refs/heads/` and open a branch-backed Gitea + pull request through the API; do not push `refs/for/*`, `refs/draft/*`, or + `refs/for-review/*`; +- use the API for review/comment operations and verify the returned object/state + before claiming the action completed. + +The prompt includes neither the token value nor `GITEA_CLAUDE_TOKEN`. Keeping +even the environment-variable name out of the bottle reduces accidental +credential probing and prevents bespoke agent prompts from needing secret +implementation details. ### Signing: sign at commit time via a forwarded ssh-agent @@ -331,6 +484,8 @@ CREATE TABLE bottled_agent_activation ( host TEXT NOT NULL, manifest_digest TEXT NOT NULL, -- ties the record to the sealed manifest agent TEXT NOT NULL, + configured_author_name TEXT NOT NULL, -- trusted agent definition value + configured_author_email TEXT NOT NULL, -- trusted agent definition value 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 @@ -359,24 +514,32 @@ 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. +activation's `configured_author_*` columns preserve the trusted agent +configuration. The attributed commit's author/committer columns are the +commit's *claim*; consumers compare the two if useful, understanding that a +mismatch is recorded but not rejected. ### Credential lifecycle -Follows PRD 0048, minus the API-token kind (dropped): +Signing follows PRD 0048's lifecycle discipline. The operator-provided forge +actor token is referenced, not provisioned: - **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. + provisioned exactly as PRD 0048. Resolve each referenced forge actor token + from the host environment and install it only in the egress proxy process. + 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. + revocation failure halts teardown (0048); 404 = already-gone = success. Stop + the egress proxy to discard its copy of the forge actor token. The + operator-owned token itself is not revoked because bot-bottle did not mint it + and it may be reused by later activations of the same trusted agent. - **Dirty teardown** is assumed handled; a separate cleanup/sync pass reconciles orphaned deploy keys. @@ -384,7 +547,8 @@ Follows PRD 0048, minus the API-token kind (dropped): 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`, +each new commit's author **and** committer name/email to equal the resolved +agent `author`, 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 @@ -394,23 +558,35 @@ 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 +1. **This PRD.** Sets the revised design and trust boundary. +2. **Trusted definition boundary.** Remove `$CWD/.bot-bottle/agents` from + discovery, override, enumeration, lazy loading, and selectors. Keep both + agent and bottle definitions home-only; warn on ignored repository files. + Update PRD 0011-facing docs and migration guidance. +3. **Identity and forge manifest surface.** Add agent-only `author` and + `forge-accounts`; remove `git-gate.user`; add bottle-only + `git-gate.signing` (`enabled` only) and + `git-gate.repos..forge`. Validate account references after composing + the selected agent+bottle and fail closed on missing host secrets or + unsupported URLs/providers. +4. **Forge proxy + prompt provisioning.** Resolve referenced actor tokens into + scoped egress proxy routes and generate provider-specific, non-secret system + instructions from typed manifest fields. Gitea guidance covers API usage, + branch-backed PRs, prohibited AGit refs, and verifying mutations. Test that + neither token values nor token secret names enter the bottle or prompt. +5. **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 +6. **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` / +7. **Control-plane verification + audit.** `bottled_agent_activation` / `attributed_commit` tables (PRD 0067 store, control-plane-owned per PRD 0070); the control plane recomputes each commit's object ID and verifies the signature before writing a row; retain full pubkey + fingerprint + principal + @@ -418,14 +594,24 @@ validating signing broker in front of the key would be required for the latter. + a post-teardown `verify-commit` helper. Tests: a gateway-claimed SHA/key/ verdict is ignored — the row's `sha` is the recomputed ID and bytes not signed by the activation key produce **no** 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). +8. **Docs.** Glossary entries for forge account and per-bottle signed commits; + README agent/bottle schema and home-only migration; 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. +- **Unit — trust boundary (must):** cwd agent files are ignored with a warning, + cannot override a home agent, are absent from enumeration/selectors, and + cannot be loaded by name. Cwd bottle behavior remains home-only. +- **Unit — manifest (must):** `author`, `forge-accounts`, + `git-gate.signing`, and repo `forge` parsing/validation; misplaced-field + rejection; kebab-case account names; unknown account references; HTTPS/API + URL validation; missing token-secret environment values. +- **Unit — prompt/proxy (must):** only referenced forge accounts produce proxy + routes and guidance; Gitea instructions name the API/repo and branch-backed + workflow; token values and `token_secret` names are absent from the prompt and + bottle environment; auth is scoped to the validated forge API origin. - **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. @@ -436,7 +622,9 @@ validating signing broker in front of the key would be required for the latter. - **Control plane (must):** the control plane recomputes the object ID and records a row for bytes genuinely signed by the activation key; a gateway- supplied SHA/key/verdict is ignored (the stored `sha` is the recomputed value); - bytes signed by a foreign/invalid key produce **no** row. + bytes signed by a foreign/invalid key produce **no** row; the activation + retains the configured agent author while a differing commit author is + recorded separately as an unenforced claim. - **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 -- 2.52.0 From 528a20e2ceadbd0ee6dd347561951d5ea4779680 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 20:35:48 +0000 Subject: [PATCH 6/7] docs: split signing from forge identity PRD --- .../prd-new-signed-commits-attribution.md | 651 ------------------ .../prd-new-trusted-agent-forge-identity.md | 387 +++++++++++ 2 files changed, 387 insertions(+), 651 deletions(-) delete mode 100644 docs/prds/prd-new-signed-commits-attribution.md create mode 100644 docs/prds/prd-new-trusted-agent-forge-identity.md diff --git a/docs/prds/prd-new-signed-commits-attribution.md b/docs/prds/prd-new-signed-commits-attribution.md deleted file mode 100644 index 37fd0585..00000000 --- a/docs/prds/prd-new-signed-commits-attribution.md +++ /dev/null @@ -1,651 +0,0 @@ -# PRD prd-new: Trusted agent forge identity, signed commits & audit attribution - -- **Status:** Draft -- **Author:** didericis-claude -- **Created:** 2026-07-25 -- **Issue:** #423 - -## Summary - -Give each **host-trusted agent definition** an author identity and named forge -accounts, and let a bottle associate each git-gate repository with one of those -accounts. The resolved association provisions authenticated forge API access -through the egress proxy and injects provider-specific workflow instructions -into the agent's system prompt without exposing the token. Repository-local -agent and bottle definitions are no longer discovered because they would -otherwise be able to select host credential references or impersonate an agent. - -For bottles that opt into signing, give each bottled agent a **per-activation -signing key** so every commit it produces is signed in the git-gate trust -boundary (outside the bottle) and recorded in bot-bottle's **host-owned audit -store**, which is the portable source of truth. Each row cryptographically binds -a commit's bytes (and their control-plane-recomputed SHA) to **access to that -activation's signing key**, and binds the key to control-plane-owned activation -metadata — bottle, host, manifest, agent, activation interval, retained public -key, configured agent author — and separately records the commit's *claimed* -author/committer. The gate mints a short-lived Ed25519 key at spin-up, holds the -private half in the sidecar `ssh-agent`, and forwards only `SSH_AUTH_SOCK` into -the bottle. The gate rejects any commit it forwards that is not signed by the -activation key; separately, the **control plane** independently recomputes each -commit's object ID and verifies its signature before recording attribution — it -never trusts a SHA, key, or verdict asserted by the gate. - -This PRD deliberately does **not** enforce or cryptographically vouch -author/committer identity. The trusted agent definition supplies the configured -name/email, while the values in each commit remain claims carried inside the -signed object; making the gate reject a mismatch is a possible future add (see -**Non-goals** and **Deferred: identity enforcement**). Git push capability stays -exactly as PRD 0048 deploy keys. Forge API identity uses an operator-provided, -agent-specific token referenced from the trusted host environment; bot-bottle -does not mint forge users or tokens. - -Successor to: - -- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — established that - agent name/email is *claimed, not vouched*. This PRD moves that identity from - the bottle/git-gate overlay to the trusted agent definition and adds signed - **provenance** plus a durable host record, not identity enforcement. -- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to - override home agents. This PRD removes that trust path: agents and bottles are - loaded only from the host-owned `~/.bot-bottle` tree. -- **PRD 0048 (deploy-key provisioning, #169)** — the host-side mint-at-spin-up / - revoke-at-teardown lifecycle the signing key follows. Deploy keys are - unchanged. -- **PRD 0070 (per-host orchestrator, #351)** — the orchestrator/control plane is - the sole owner of `bot-bottle.db`; audit verification and recording live - there, not in the data-plane gate (see **Trust boundary**). - -## The guarantee - -The crisp property this feature provides: - -> The **host-owned audit store** binds a set of commit bytes — whose Git object -> ID the control plane **recomputes** itself — to **access to this activation's -> signing key**, and binds that key to **control-plane-owned activation -> metadata**: bottle, host, manifest, agent, activation interval, retained public -> key, configured agent author. An agent may author and sign arbitrary commit -> contents, but it cannot make that signature verify as a *different* -> activation, and it cannot choose the activation metadata the control plane -> records. The commit's author/committer identity is **recorded separately as a -> claim**, not enforced or vouched. The forge is an external transport and -> collaboration surface, not the source of attribution truth. - -What this does and does not prove (issue #423, comments #5554 / #5607 / #5608): - -- It proves **access to activation *Y*'s signing key**: whoever assembled these - commit bytes could sign with that key. Recomputing the object ID and verifying - the embedded signature binds the SHA to activation *Y*, and the control plane's - own records bind *Y*'s key to *Y*'s metadata. -- It does **not** prove the commit was ever pushed, observed upstream, kept - (vs. later reverted or dropped), or produced by the *agent* rather than by any - other holder of the activation signing capability (the sidecar itself). The - store deliberately makes no claim about publication or sole-agent authorship - — the owner's requirement is attribution of *what manifest/agent/etc. was in - use when a commit was signed*, not proof of where the commit went (#5607). -- It does **not** make author/committer identity cryptographically vouched. The - bottle chooses every byte sent through the forwarded agent, so a signature over - `author Mallory ` is just as valid. Those fields are a claim - carried inside the signed object and recorded as-is. -- The binding is trustworthy because the **control plane** supplies the SHA (it - recomputes it), the public key, and the activation metadata from its own state - — never from a value the gateway asserts (see **Trust boundary**). The - residual, by design: anything that holds the activation signing capability can - produce commits that attribute to that activation. That is inherent to a - binding on *activation-key access*, not a defect. - -## Problem - -An agent runs on the developer's machine as a *subrole*, scoped down per role. -Locally that is fine because the machine is single-tenant. The git history an -agent produces, however, is a durable artifact that outlives the session and -can be pushed to shared repositories, and today bot-bottle offers no -tamper-evidence over it: - -- **No provenance.** Nothing ties a pushed commit to the bottle/activation that - actually produced it. The configured 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. -- **Forge workflow context is missing.** The agent prompt does not know which - forge backs a git-gate repository, which API base URL to use, or that - authenticated requests must go through the egress proxy. Bespoke prompt text - has drifted between agents, causing incorrect PR creation behavior such as - using Gitea AGit review refs instead of a branch-backed pull request. -- **Identity is owned by the wrong layer.** `git-gate.user` puts an agent - property on a repository transport component. The same author identity and - forge actor should follow the agent across bottles and repositories. -- **Repository-local agents are a trust escalation.** Today - `$CWD/.bot-bottle/agents/*.md` can override a host agent. Once an agent - definition may reference a forge token, allowing the checked-out repository - to choose that definition would let untrusted workspace content select host - identities and credentials. - -## 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. -- **Agent-owned identity.** Author name/email and named forge accounts live on - the agent definition, not under `git-gate`. -- **Bottle-owned repository policy.** Signing remains an opt-in property of the - bottle, and each bottle repository may associate itself with one named forge - account from the selected agent. -- **Forge-aware prompting.** The resolved agent+bottle manifest contributes a - generated, provider-specific system-prompt section describing the forge API - URL, proxy-authenticated access path, repository mapping, and safe PR - workflow. No token value or token environment-variable name appears in the - prompt. -- **Proxy-held forge credential.** The host resolves the forge account's token - reference and gives it only to the egress proxy, which injects authentication - for the configured forge origin. The bottle receives neither the token nor a - credential file containing it. -- **Trusted definitions only.** Agent and bottle files are discovered only - under the host-owned `~/.bot-bottle/{agents,bottles}` directories. - `$CWD/.bot-bottle/{agents,bottles}` never contributes definitions or - overrides. -- **Signed commits with no SHA divergence.** Commits produced in the bottle are - signed at commit time; the SHA the agent observes is the SHA that reaches the - upstream through the gate. -- **Gate rejects unsigned commits.** Before the gate forwards a push, every - newly-introduced commit (those not already reachable from the advertised - upstream refs) must verify against the activation public key; a push with any - unsigned or wrong-key new commit is rejected, loudly, with the offending SHA. - This is a **signature** check only — no author/committer matching. -- **Control-plane-owned attribution.** The orchestrator/control plane (sole - owner of `bot-bottle.db`, PRD 0070) recomputes each commit's object ID from the - bytes, verifies the embedded signature against the activation public key it - holds, and attaches activation metadata from its own state — accepting no SHA, - key, verdict, or metadata asserted by the gateway. No upstream fetch is - required. -- **Host is the source of truth.** The audit record binds each recomputed SHA to - the bottle, host, manifest, agent, configured agent author, activation - interval, and retained public key, and separately 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.** Git transport remains PRD 0048 deploy keys. - The forge account token is for forge API actions such as opening and - commenting on pull requests; it is not used for Git push. - -## 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 account or token minting.** Bot-bottle does not create subusers or - PATs. The operator creates the agent-specific account/token out of band and - names the host environment secret in the trusted agent definition. -- **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 evolution - -This PRD started as "forge subroles" (forge subuser accounts + provisioned API -tokens + optional forge status posting + signing). Review first narrowed it, -then restored only the part needed for correct agent operation: - -1. **Dropped forge account/token provisioning** (#5518 → #5556): the PAT - bootstrap is not implementable as sketched (Basic-Auth-as-target-user - constraint), and the signature never vouched the author anyway. The host - audit store remains the portable source of truth. -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. -3. **Restored declarative forge accounts, not provisioning** (#6002): agents - still need an operator-supplied API identity and forge-specific system - instructions to open and update PRs correctly. The trusted agent definition - therefore references an existing host secret; bot-bottle neither creates nor - rotates that credential. -4. **Moved identity to the agent trust domain** (#6002): author and forge - accounts are agent properties. Bottle repositories select an account by - name, while git-gate remains transport-only. Because these references grant - access to host credentials, repository-local agent/bottle discovery is - removed. - -The resulting feature is **trusted agent identity + operator-provided forge API -access + forge-aware prompting + signed commits + a host-owned, -independently-verified audit record.** Identity enforcement (the gate rejecting -a foreign author/committer) remains a candidate future PRD. - -## Design - -### Trust boundary (control plane vs data plane) - -git-gate is the **data plane**: it parses hostile bytes from inside the bottle -and forwards pushes. The orchestrator is the **control plane** and, per PRD -0070, is the sole owner of `bot-bottle.db`. These are different trust boundaries, -and the audit record must be anchored in the control plane: - -- The gate performs a **synchronous pre-forward signature check** (below) and - can reject a push before it reaches the upstream. This is a data-plane gate on - what leaves the bottle, not the audit binding. -- The **control plane** takes the commit bytes to attribute (gateway-delivered - opaque bytes are fine), **recomputes the Git object ID**, **verifies the - embedded signature** against the activation public key it minted and holds, and - writes `attributed_commit` attaching metadata from its own state. It accepts - **no** gateway-supplied `verified` flag, claimed SHA, public key, or activation - identity. - -The precise trust statement (issue #423, review by didericis-codex on d8362ec, -resolved in #5608): the row binds *these commit bytes / this recomputed SHA* to -*access to this activation's signing key*, and the control plane binds that key -to the recorded activation metadata. It does **not** assert forge observation or -that only the agent (not the signing sidecar) authored the commit — so this PRD -does **not** claim a compromised gateway cannot obtain an attribution row. -Because the sidecar holds the activation signing capability, a compromised -gateway *can* assemble and sign a commit and have it attributed to that -activation; what it cannot do is make the signature verify as a *different* -activation or choose the metadata the control plane records. That residual is -acceptable under the intended guarantee (#5607) and is why the guarantee is -worded as activation-key access, not agent-only authorship or upstream -publication. The gate therefore cannot stand in for host-side verification: the -control plane recomputes the object ID and verifies the signature itself rather -than trusting the gate's word. - -### Identity model - -Per **bottled agent** (agent definition ∘ sealed bottle), realized per -activation: - -| Part | Value | Source | Role | -|------|-------|--------|------| -| Signing key | one Ed25519 keypair | minted host-side per activation | signs every commit; private half sidecar-only; the anchor of provenance | -| Author/committer | name + email | trusted agent definition `author` | written into commits and **recorded** as a claim; **not** enforced | -| Forge actor | named account + API origin + token reference | trusted agent definition `forge-accounts` | authenticates forge API actions through the egress proxy | -| Repository/forge association | forge account name | bottle `git-gate.repos..forge` | selects the actor and generated workflow guidance for that repository | - -### Manifest surface - -Identity belongs to the agent. Repository capability and policy belong to the -bottle. The following files are both host-owned: - -```yaml -# ~/.bot-bottle/agents/claude.md ---- -author: - name: didericis-claude - email: eric+claude@dideric.is -forge-accounts: - didericis-gitea: - auth: - type: token - token_secret: GITEA_CLAUDE_TOKEN - url: https://gitea.dideric.is/api/v1 ---- -``` - -```yaml -# ~/.bot-bottle/bottles/dev.md ---- -git-gate: - 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..." - forge: didericis-gitea # account from the selected agent definition ---- -``` - -- `author` is agent-only and replaces the `git-gate.user` agent/bottle overlay. - It is required when `git-gate.signing.enabled` is true or when any selected - repository has a `forge` association. The resolved values populate - `user.name` and `user.email`. -- `forge-accounts` is an agent-only map keyed by the existing manifest - kebab-case identifier grammar (`[a-z][a-z0-9-]*`). Each entry contains: - - `url`: an HTTPS forge API base URL. This PRD supports Gitea API URLs; a - future provider must add explicit typed validation and prompt generation - rather than accepting arbitrary prompt text supplied by a repository. - - `auth.type`: `token` in this slice. - - `auth.token_secret`: the name of a host environment variable containing the - operator-provided, agent-specific API token. The value is resolved only by - host provisioning and passed only to the egress proxy. -- `git-gate.repos..forge` is bottle-only and must resolve to an account in - the selected agent definition. An unknown account, an unsupported forge URL, - a non-HTTPS URL, or a missing/empty host secret fails launch before creating - the bottle. -- `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`, `git-gate.repos`, and their `forge` associations are - bottle-only. `author` and `forge-accounts` are agent-only. Validation errors - point to the correct file/type instead of silently ignoring misplaced keys. -- `provisioned_key.token_env` remains the deploy-key administration credential - from PRD 0048. It is separate from the forge actor's `token_secret`: the - former provisions Git push capability, while the latter performs API actions - as the agent. -- Existing `git-gate.user` fields fail with migration guidance to move the - values into the selected home agent's `author` block. There is no period where - bottle identity silently overrides agent identity. - -### Definition trust and discovery - -Only the host-owned manifest tree is authoritative: - -- Agents: `~/.bot-bottle/agents/*.md` -- Bottles: `~/.bot-bottle/bottles/*.md` - -`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer -overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If -either repository-local directory contains manifest files, bot-bottle emits a -warning that they are ignored and points to the home paths. Agent enumeration, -`require_agent`, lazy loading, and dashboard selectors all use the same -home-only index so there is no alternate path that can still select a workspace -definition. - -This intentionally supersedes PRD 0011's repository-agent overlay. Workspace -instructions remain repository content (for example `AGENTS.md`), but executable -runtime policy, host secret references, and actor identity do not. - -Programmatic in-memory manifests remain available for tests and internal -composition; they are already supplied by trusted host code and are not a -filesystem discovery path. - -### Forge API provisioning and generated prompt - -For each distinct forge account referenced by the selected bottle's repos, the -host: - -1. Parses and canonicalizes the HTTPS API origin and rejects credentials in the - URL, fragments, and unsupported path shapes. -2. Resolves `auth.token_secret` from the host environment. The secret value is - copied only into the egress proxy's credential environment. -3. Adds an inspected egress route scoped to that forge origin/API prefix with - the provider's authentication scheme (`token` for Gitea). Authentication is - injected by the proxy; the bottle sends an unauthenticated request to the - configured URL. -4. Appends a generated, non-secret section to bot-bottle's existing system - prompt file. The section is derived from validated typed fields, not copied - Markdown from a repository. - -For the example above, the generated guidance communicates: - -- account alias `didericis-gitea` and API base - `https://gitea.dideric.is/api/v1`; -- repository `bot-bottle` uses that account; -- forge API calls must use the configured HTTPS URL through the proxy and must - not read, print, or manually attach an authorization token; -- Git pushes still use the bottle's git-gate remote; -- create/update a normal `refs/heads/` and open a branch-backed Gitea - pull request through the API; do not push `refs/for/*`, `refs/draft/*`, or - `refs/for-review/*`; -- use the API for review/comment operations and verify the returned object/state - before claiming the action completed. - -The prompt includes neither the token value nor `GITEA_CLAUDE_TOKEN`. Keeping -even the environment-variable name out of the bottle reduces accidental -credential probing and prevents bespoke agent prompts from needing secret -implementation details. - -### 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 - -For each commit to attribute (the gate hands the control plane the commit bytes; -opaque gateway-delivered bytes are acceptable because nothing the gateway *says* -about them is trusted), the orchestrator/control plane: - -1. **Recomputes the Git object ID** from the bytes itself. The stored `sha` is - this recomputed value, never a SHA the gateway claims. -2. **Verifies the embedded signature** against the activation public key it - minted and holds for that activation (via a generated allowed-signers file) — - ignoring any `verified` flag, key, or activation identity supplied by the - gateway. -3. Writes `attributed_commit` only for bytes that pass, stamping the activation - metadata (bottle/manifest/agent/host/interval) from its **own** state — not - from anything the gateway provides — and recording the commit's claimed - author/committer. - -No upstream fetch is required: the guarantee is a byte↔activation-key binding, so -the object does not need to come from the forge (issue #423, #5608). Bytes that -do not verify against the activation key are **not** recorded as attributed (they -may be logged as an anomaly instead). - -### Audit trail - -The host SQLite store (PRD 0067, `~/.bot-bottle/bot-bottle.db`, owned by the -control plane per PRD 0070) records the signing-key lifecycle and per-commit -attribution. Retention is the **full public key, fingerprint, principal, and -validity interval** — enough to regenerate an allowed-signers file and verify -commits after teardown (issue #423, comment #5554, resolution 3). Never any -private key material. - -```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, - configured_author_name TEXT NOT NULL, -- trusted agent definition value - configured_author_email TEXT NOT NULL, -- trusted agent definition value - signing_pubkey TEXT NOT NULL, -- full ssh-ed25519 public key (for verify-commit) - signing_fpr TEXT NOT NULL, -- SHA256:... fingerprint (stable handle) - principal TEXT NOT NULL, -- allowed-signers principal, e.g. the author email - valid_from TEXT NOT NULL, - valid_until TEXT, -- NULL while active; set at teardown - status TEXT NOT NULL, -- active | retired - PRIMARY KEY (bottled_agent_slug, activation_id) -); - -CREATE TABLE attributed_commit ( - sha TEXT NOT NULL, -- control-plane-RECOMPUTED object ID, not gateway-claimed - bottled_agent_slug TEXT NOT NULL, - activation_id TEXT NOT NULL, - repo TEXT NOT NULL, - author_name TEXT NOT NULL, -- CLAIMED, recorded as-is (not enforced) - author_email TEXT NOT NULL, -- CLAIMED - committer_name TEXT NOT NULL, -- CLAIMED - committer_email TEXT NOT NULL, -- CLAIMED - observed_at TEXT NOT NULL, - PRIMARY KEY (sha, repo) -); -``` - -Verification/allowed-signers generation is a stated part of the design: for a -given SHA, join `attributed_commit → bottled_agent_activation`, emit -` ` 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 -activation's `configured_author_*` columns preserve the trusted agent -configuration. The attributed commit's author/committer columns are the -commit's *claim*; consumers compare the two if useful, understanding that a -mismatch is recorded but not rejected. - -### Credential lifecycle - -Signing follows PRD 0048's lifecycle discipline. The operator-provided forge -actor token is referenced, not provisioned: - -- **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. Resolve each referenced forge actor token - from the host environment and install it only in the egress proxy process. - 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. Stop - the egress proxy to discard its copy of the forge actor token. The - operator-owned token itself is not revoked because bot-bottle did not mint it - and it may be reused by later activations of the same trusted agent. -- **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 the resolved -agent `author`, -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 revised design and trust boundary. -2. **Trusted definition boundary.** Remove `$CWD/.bot-bottle/agents` from - discovery, override, enumeration, lazy loading, and selectors. Keep both - agent and bottle definitions home-only; warn on ignored repository files. - Update PRD 0011-facing docs and migration guidance. -3. **Identity and forge manifest surface.** Add agent-only `author` and - `forge-accounts`; remove `git-gate.user`; add bottle-only - `git-gate.signing` (`enabled` only) and - `git-gate.repos..forge`. Validate account references after composing - the selected agent+bottle and fail closed on missing host secrets or - unsupported URLs/providers. -4. **Forge proxy + prompt provisioning.** Resolve referenced actor tokens into - scoped egress proxy routes and generate provider-specific, non-secret system - instructions from typed manifest fields. Gitea guidance covers API usage, - branch-backed PRs, prohibited AGit refs, and verifying mutations. Test that - neither token values nor token secret names enter the bottle or prompt. -5. **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. -6. **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). -7. **Control-plane verification + audit.** `bottled_agent_activation` / - `attributed_commit` tables (PRD 0067 store, control-plane-owned per PRD 0070); - the control plane recomputes each commit's object ID and verifies the - signature before writing a row; retain full pubkey + fingerprint + principal + - validity interval; record claimed author/committer; allowed-signers generation - + a post-teardown `verify-commit` helper. Tests: a gateway-claimed SHA/key/ - verdict is ignored — the row's `sha` is the recomputed ID and bytes not signed - by the activation key produce **no** row. -8. **Docs.** Glossary entries for forge account and per-bottle signed commits; - README agent/bottle schema and home-only migration; 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 — trust boundary (must):** cwd agent files are ignored with a warning, - cannot override a home agent, are absent from enumeration/selectors, and - cannot be loaded by name. Cwd bottle behavior remains home-only. -- **Unit — manifest (must):** `author`, `forge-accounts`, - `git-gate.signing`, and repo `forge` parsing/validation; misplaced-field - rejection; kebab-case account names; unknown account references; HTTPS/API - URL validation; missing token-secret environment values. -- **Unit — prompt/proxy (must):** only referenced forge accounts produce proxy - routes and guidance; Gitea instructions name the API/repo and branch-backed - workflow; token values and `token_secret` names are absent from the prompt and - bottle environment; auth is scoped to the validated forge API origin. -- **Integration — signing (must):** end-to-end signed commit verifies with - `git verify-commit`; SHA observed in the bottle equals the SHA upstream; the - private key is absent from the bottle. -- **Integration — gate check (must):** unsigned rejected; wrong-key rejected; - the upstream-reachable exclusion (pull + merge human history and push a signed - merge); a correctly-signed foreign-author commit **passes** (no identity - enforcement); a clean all-signed push succeeds. -- **Control plane (must):** the control plane recomputes the object ID and - records a row for bytes genuinely signed by the activation key; a gateway- - supplied SHA/key/verdict is ignored (the stored `sha` is the recomputed value); - bytes signed by a foreign/invalid key produce **no** row; the activation - retains the configured agent author while a differing commit author is - recorded separately as an unenforced claim. -- **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. - -## Resolved: control-plane transport - -Raised in review and **resolved** (issue #423, #5608): the commit object does not -need to come from the forge, and reading the gateway-owned mirror is no stronger -than accepting gateway-delivered bytes — both are fabricatable, and neither -matters because the control plane trusts nothing the gateway *asserts*. The -transport is therefore: the gate hands the control plane the raw commit bytes, -the control plane **recomputes the object ID** and **verifies the signature** -against the activation key, and stamps its own activation metadata. No upstream -fetch. This is exactly what makes the byte↔activation-key binding sound -regardless of transport. - -## Open questions - -- **Where the gate check slots into PRD 0008 ordering.** Modeled as a - pre-forward step after gitleaks; confirm it composes with the existing - access-hook / mirror ordering rather than needing a separate hook. diff --git a/docs/prds/prd-new-trusted-agent-forge-identity.md b/docs/prds/prd-new-trusted-agent-forge-identity.md new file mode 100644 index 00000000..4201438c --- /dev/null +++ b/docs/prds/prd-new-trusted-agent-forge-identity.md @@ -0,0 +1,387 @@ +# PRD prd-new: Trusted agent forge identity and guidance + +- **Status:** Draft +- **Author:** didericis-claude +- **Created:** 2026-07-25 +- **Issue:** #423 + +## Summary + +Move author identity and named forge accounts into host-trusted agent +definitions. A bottle may optionally associate a git-gate repository with one +of the selected agent's forge accounts. When associated, bot-bottle gives the +agent non-secret, provider-specific system guidance for that repository and +routes authenticated forge API calls through the egress proxy without exposing +the account token to the bottle. + +Repository-local agent and bottle definitions are no longer discovered. Once +agent definitions can select a host forge credential, allowing checked-out +repository content to define or override an agent would let untrusted workspace +content select host identities and secrets. + +This PRD is deliberately limited to identity ownership, manifest trust, forge +API access, and generated guidance. Per-activation signing, signature +enforcement, commit attribution, and the commit audit model move to a follow-up +PRD. + +Successor to: + +- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to + override home agents. This PRD removes that trust path: agents and bottles are + loaded only from the host-owned `~/.bot-bottle` tree. +- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — put name/email in + `git-gate.user` while keeping them claimed rather than vouched. This PRD moves + those agent properties out of git-gate and into the trusted agent definition. +- **PRD 0048 (deploy-key provisioning, #169)** — remains the Git push + capability. A forge actor token is a separate API credential and never + replaces a deploy key. + +## Problem + +### Forge workflow context is missing + +The agent prompt does not know which forge backs a git-gate repository, which +API base URL to use, or that authenticated requests must go through the egress +proxy. Bespoke prompt text has drifted between agents. Missing guidance has +already caused incorrect PR behavior, including attempts to use Gitea AGit +review refs instead of a normal branch-backed pull request. + +### Identity is owned by the wrong layer + +`git-gate.user` puts an agent property on a repository transport component. An +author identity and forge actor should follow the agent across bottles and +repositories. Git-gate should own Git transport policy, not decide who the +agent is. + +### Repository-local agents become a credential-selection path + +Today `$CWD/.bot-bottle/agents/*.md` can define new agents and override +home-resident agents. If an agent definition may reference an operator-provided +forge token, a malicious repository could select a host credential merely by +being the current workspace. Repository-local bottles are already ignored; +agents need the same host-only boundary. + +## Goals / Success Criteria + +- **Agent-owned identity.** Author name/email and named forge accounts live on + the agent definition, not under `git-gate`. +- **Trusted definitions only.** Agent and bottle files are discovered only + under `~/.bot-bottle/{agents,bottles}`. Repository-local definitions never + contribute names, defaults, or overrides. +- **Optional repository association.** A bottle repository may name one forge + account from the selected agent. Repositories without `forge` retain current + behavior and generate no forge guidance or actor credential route. +- **Proxy-held forge credential.** The host resolves the selected forge + account's token reference and gives the value only to the egress proxy. The + bottle receives neither the token nor a credential file containing it. +- **Forge-aware system guidance.** For associated repositories, bot-bottle + generates provider-specific instructions describing the API base URL, + repository/account association, proxy-authenticated access, and correct PR + workflow. +- **No secret prompt material.** Neither token values nor token + environment-variable names appear in the prompt or bottle environment. +- **Fail closed.** Unknown account references, unsupported/non-HTTPS URLs, + missing host secrets, and misplaced agent/bottle fields fail before creating + the bottle. +- **Push capability unchanged.** Git transport remains PRD 0048 deploy keys. + The forge actor token is only for API actions such as opening, reviewing, and + commenting on pull requests. + +## Non-goals + +- **Commit signing or signature enforcement.** No signing key is minted and + git-gate does not verify commit signatures in this PRD. +- **Commit attribution or audit tables.** There is no trustworthy commit + observation event in this slice. A follow-up signing PRD owns activation keys, + control-plane verification, and any configured-author-versus-claimed-author + audit model. +- **Cryptographically vouched author identity.** `author` configures Git and + identifies the agent actor, but commit author/committer fields remain claims + under ADR 0002. +- **Forge account or token minting.** The operator creates the agent-specific + account/token out of band. Bot-bottle references an existing host secret; it + does not create, rotate, or revoke that credential. +- **Forge-side commit attribution surfaces.** No commit status, signing-key + registration, or "Verified" badge. +- **Provider-generic arbitrary prompts.** Gitea is the first supported forge. + A future provider adds typed validation and generated guidance in code rather + than accepting repository-supplied prompt text. + +## Design + +### Ownership model + +The resolved bottled agent is an agent definition composed with a bottle: + +| Part | Source | Role | +|------|--------|------| +| Author identity | agent `author` | configures Git name/email and identifies the agent's claimed author | +| Forge accounts | agent `forge-accounts` | names API origins and host token references available to this agent | +| Git repository | bottle `git-gate.repos` | configures Git transport, host verification, and push capability | +| Repository/forge association | bottle repo `forge` | optionally selects an agent forge account for guidance and API access | + +This boundary keeps identity on the agent, capability/policy on the bottle, and +transport enforcement in git-gate. + +### Agent manifest + +Agent identity and forge accounts live only in +`~/.bot-bottle/agents/.md`: + +```yaml +--- +author: + name: didericis-claude + email: eric+claude@dideric.is +forge-accounts: + didericis-gitea: + auth: + type: token + token_secret: GITEA_CLAUDE_TOKEN + url: https://gitea.dideric.is/api/v1 +--- +``` + +`author` contains: + +- `name`: non-empty string; +- `email`: non-empty string passing the existing Git identity validation. + +The resolved values populate `user.name` and `user.email`. Existing +`git-gate.user` fields fail with migration guidance to move the values into the +selected home agent's `author` block. There is no compatibility period where a +bottle identity silently overrides the agent identity. + +`forge-accounts` is a map whose keys follow the manifest's existing kebab-case +identifier grammar (`[a-z][a-z0-9-]*`). Each account contains: + +- `url`: a canonical HTTPS Gitea API base URL; +- `auth.type`: `token` in this slice; +- `auth.token_secret`: the name of a host environment variable containing the + operator-provided, agent-specific API token. + +The token secret name is host configuration, not bottle configuration. The +token value is resolved only if a selected bottle repository references the +account. + +### Bottle manifest + +Repository policy remains in `~/.bot-bottle/bottles/.md`: + +```yaml +--- +git-gate: + repos: + bot-bottle: + url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git + provisioned_key: + provider: gitea + token_env: GITEA_DEPLOY_TOKEN + host_key: "ssh-ed25519 AAAA..." + forge: didericis-gitea +--- +``` + +`git-gate.repos..forge` is optional: + +- When absent, the repository behaves exactly as it does today. Bot-bottle does + not resolve a forge actor token and does not add forge-specific instructions + for that repository. +- When present, it must match a `forge-accounts` entry on the selected agent. + The resolved association enables a scoped proxy credential route and adds the + repository/forge relationship to generated system guidance. + +`provisioned_key.token_env` remains the deploy-key administration credential +from PRD 0048. It is separate from `forge-accounts.*.auth.token_secret`: the +former provisions Git push capability, while the latter performs API actions as +the agent. + +`author` and `forge-accounts` are agent-only. `git-gate.repos`, including +`forge`, is bottle-only. Validation errors point to the correct file and trust +domain rather than ignoring misplaced keys. + +### Definition trust and discovery + +Only the host-owned manifest tree is authoritative: + +- Agents: `~/.bot-bottle/agents/*.md` +- Bottles: `~/.bot-bottle/bottles/*.md` + +`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer +overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If +either repository-local directory contains manifest files, bot-bottle emits a +warning that they are ignored and points to the corresponding home path. + +Every discovery and resolution surface uses the same home-only index: + +- agent enumeration and selectors; +- `require_agent`; +- lazy `load_for_agent`; +- default-agent/default-bottle resolution; +- dashboard and headless launch paths. + +There must be no alternate direct-path load that can still select a workspace +definition. + +Workspace instructions remain repository content (for example `AGENTS.md`), +but runtime policy, host secret references, and actor identity do not. +Programmatic in-memory manifests remain available for tests and trusted internal +composition; they are not a filesystem discovery path. + +### Forge URL validation + +The host parses and canonicalizes each referenced forge account URL before +creating any runtime resources: + +- scheme must be `https`; +- userinfo, query, and fragment are forbidden; +- hostname must be present; +- the path must be a supported Gitea API base (initially `/api/v1`, with + normalization of a trailing slash); +- visually different inputs that canonicalize to the same origin/prefix are + deduplicated; +- unsupported providers or path shapes fail closed. + +The provider is determined by typed support in bot-bottle, not by prompt text +from a repository. Adding another provider requires a validator, auth scheme, +and guidance renderer. + +### Proxy credential provisioning + +For each distinct forge account referenced by at least one selected bottle +repository, the host: + +1. Resolves `auth.token_secret` from the host environment and rejects a missing + or empty value. +2. Copies the token value only into the egress proxy's credential environment. +3. Adds an inspected route scoped to the canonical forge origin and API prefix. +4. Configures the provider authentication scheme (`token` for Gitea) so the + proxy injects authentication. + +The bottle makes an unauthenticated request to the configured HTTPS API URL. +The token is not copied into the bottle, `.gitconfig`, generated prompt, +workspace, or process environment visible to the agent. + +If several repositories reference the same forge account, they share one +credential route. Unreferenced forge accounts resolve no secret and create no +route. + +### Generated system guidance + +Bot-bottle appends a generated, non-secret section to its existing system +prompt file. It is derived from validated typed fields, not copied Markdown from +a repository. + +For each associated repository, Gitea guidance includes: + +- account alias (for example `didericis-gitea`); +- API base URL (for example `https://gitea.dideric.is/api/v1`); +- the git-gate repository name tied to that forge; +- the instruction to call the configured HTTPS API through the proxy without + reading, printing, or manually attaching an authorization token; +- the distinction that Git pushes still use the git-gate remote; +- the requirement to create/update a normal `refs/heads/` and open a + branch-backed pull request through the API; +- the prohibition on pushing `refs/for/*`, `refs/draft/*`, or + `refs/for-review/*`; +- the instruction to use the API for reviews/comments and verify returned + object state before claiming completion. + +The prompt contains neither the token value nor its `token_secret` name. +Repositories without `forge` are omitted from this section. If no selected +repository has a forge association, no forge guidance section is generated. + +### Failure and lifecycle behavior + +Forge configuration is validated before bottle creation. A bad association or +credential must not leave a partially-created bottle or proxy. + +The operator-owned token is not minted, rotated, or revoked by bot-bottle. On +teardown, stopping the egress proxy discards the activation's in-memory/runtime +copy. Later activations resolve the current host secret again. + +Logs may include the account alias, canonical API origin, and repository name. +They must never contain the token value. Errors for missing secrets name the +configuration field and host environment variable, but do not print any value. + +## Migration + +This change is intentionally breaking at the manifest trust boundary: + +1. Move each home bottle/agent `git-gate.user.name` and `.email` into the + corresponding home agent's `author`. +2. Add agent-specific `forge-accounts` only to home agent definitions. +3. Add optional `forge` associations to home bottle repository entries. +4. Move any `$CWD/.bot-bottle/agents/*.md` that should remain selectable into + `~/.bot-bottle/agents/`. Repository copies are ignored thereafter. +5. Keep repository-specific behavioral instructions in `AGENTS.md` or another + workspace instruction file; do not put runtime identity or secret references + there. + +Errors and warnings link to this migration rather than silently changing which +identity or definition is active. + +## Follow-up: signed commits and attribution + +A separate PRD will consume the trusted agent `author` introduced here and own: + +- per-activation signing key minting and sidecar isolation; +- commit-time signing through a forwarded signing capability; +- git-gate rejection of unsigned/wrong-key new commits; +- independent control-plane object-ID recomputation and signature verification; +- activation and attributed-commit audit tables; +- storage of configured agent author separately from each commit's unenforced + claimed author/committer; +- post-teardown verification and key-retention policy. + +That PRD must not reintroduce identity under git-gate or expand repository-local +manifest trust. + +## Implementation chunks + +1. **This PRD.** Establish the identity, forge, and filesystem trust boundary. +2. **Home-only definitions.** Remove cwd agents from discovery, override, + enumeration, lazy loading, defaults, and selectors. Warn on ignored cwd + agent/bottle files and provide migration guidance. +3. **Manifest schema.** Add agent-only `author` and `forge-accounts`; remove + `git-gate.user`; add optional bottle-only + `git-gate.repos..forge`. Validate account names, composition + references, field placement, and Gitea API URLs. +4. **Proxy provisioning.** Lazily resolve only referenced token secrets and + create scoped authenticated Gitea API routes without putting credentials in + the bottle. +5. **Prompt generation.** Render typed Gitea/repository workflow guidance into + the existing bot-bottle prompt path for associated repositories only. +6. **Docs and migration.** Update README examples, PRD 0011-facing discovery + documentation, agent/bottle schema docs, and error guidance. + +## Testing strategy + +- **Trust boundary:** cwd agent files are ignored with a warning, cannot + override a home agent, are absent from enumeration/selectors/defaults, and + cannot be loaded by name. Cwd bottle behavior remains home-only. +- **Agent schema:** `author` and `forge-accounts` parse; malformed identities, + non-kebab account names, unknown fields, invalid auth types, and misplaced + git-gate fields fail clearly. +- **Bottle schema:** `forge` is optional; absent associations preserve current + behavior; present associations resolve against the selected agent; unknown or + misplaced associations fail before launch. +- **URL validation:** HTTPS Gitea API bases pass and canonicalize; HTTP, + userinfo, query, fragment, missing host, unsupported paths, and unsupported + providers fail closed. +- **Secret handling:** only referenced accounts resolve environment secrets; + missing/empty secrets fail before runtime creation; token values are absent + from bottle env, prompt, generated config, logs, and workspace; token secret + names are absent from the bottle and prompt. +- **Proxy behavior:** associated API requests receive proxy-injected Gitea + authentication scoped to the configured origin/prefix; unrelated hosts and + paths receive no credential. +- **Prompt behavior:** guidance names account/API/repository associations, + branch-backed PR workflow, prohibited AGit refs, and mutation verification; + repositories without `forge` are omitted; no associations means no section. +- **Migration:** legacy `git-gate.user` fails with an `author` migration pointer; + ignored cwd definitions warn with the target home path. + +## Open questions + +- None. -- 2.52.0 From f351f5f93e575e8e48f03a7aae42743f4640cbd6 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 20:51:34 +0000 Subject: [PATCH 7/7] docs: clarify forge alias identity --- .../prd-new-trusted-agent-forge-identity.md | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/docs/prds/prd-new-trusted-agent-forge-identity.md b/docs/prds/prd-new-trusted-agent-forge-identity.md index 4201438c..60ce086b 100644 --- a/docs/prds/prd-new-trusted-agent-forge-identity.md +++ b/docs/prds/prd-new-trusted-agent-forge-identity.md @@ -7,12 +7,13 @@ ## Summary -Move author identity and named forge accounts into host-trusted agent +Move author identity and named forge configurations into host-trusted agent definitions. A bottle may optionally associate a git-gate repository with one -of the selected agent's forge accounts. When associated, bot-bottle gives the +of the selected agent's forge aliases. When associated, bot-bottle gives the agent non-secret, provider-specific system guidance for that repository and routes authenticated forge API calls through the egress proxy without exposing -the account token to the bottle. +the forge token to the bottle. The authenticated account is inferred from the +identity that owns that token; it is not separately declared in the manifest. Repository-local agent and bottle definitions are no longer discovered. Once agent definitions can select a host forge credential, allowing checked-out @@ -49,9 +50,9 @@ review refs instead of a normal branch-backed pull request. ### Identity is owned by the wrong layer `git-gate.user` puts an agent property on a repository transport component. An -author identity and forge actor should follow the agent across bottles and -repositories. Git-gate should own Git transport policy, not decide who the -agent is. +author identity and set of forge credentials should follow the agent across +bottles and repositories. Git-gate should own Git transport policy, not decide +who the agent is. ### Repository-local agents become a credential-selection path @@ -63,16 +64,16 @@ agents need the same host-only boundary. ## Goals / Success Criteria -- **Agent-owned identity.** Author name/email and named forge accounts live on - the agent definition, not under `git-gate`. +- **Agent-owned identity.** Author name/email and named forge configurations + live on the agent definition, not under `git-gate`. - **Trusted definitions only.** Agent and bottle files are discovered only under `~/.bot-bottle/{agents,bottles}`. Repository-local definitions never contribute names, defaults, or overrides. - **Optional repository association.** A bottle repository may name one forge - account from the selected agent. Repositories without `forge` retain current - behavior and generate no forge guidance or actor credential route. + alias from the selected agent. Repositories without `forge` retain current + behavior and generate no forge guidance or credential route. - **Proxy-held forge credential.** The host resolves the selected forge - account's token reference and gives the value only to the egress proxy. The + alias's token reference and gives the value only to the egress proxy. The bottle receives neither the token nor a credential file containing it. - **Forge-aware system guidance.** For associated repositories, bot-bottle generates provider-specific instructions describing the API base URL, @@ -116,9 +117,9 @@ The resolved bottled agent is an agent definition composed with a bottle: | Part | Source | Role | |------|--------|------| | Author identity | agent `author` | configures Git name/email and identifies the agent's claimed author | -| Forge accounts | agent `forge-accounts` | names API origins and host token references available to this agent | +| Forge configurations | agent `forge-accounts` | maps forge aliases to API origins and host token references available to this agent; token ownership determines account identity | | Git repository | bottle `git-gate.repos` | configures Git transport, host verification, and push capability | -| Repository/forge association | bottle repo `forge` | optionally selects an agent forge account for guidance and API access | +| Repository/forge association | bottle repo `forge` | optionally selects an agent forge alias for guidance and API access | This boundary keeps identity on the agent, capability/policy on the bottle, and transport enforcement in git-gate. @@ -152,17 +153,19 @@ The resolved values populate `user.name` and `user.email`. Existing selected home agent's `author` block. There is no compatibility period where a bottle identity silently overrides the agent identity. -`forge-accounts` is a map whose keys follow the manifest's existing kebab-case -identifier grammar (`[a-z][a-z0-9-]*`). Each account contains: +`forge-accounts` is a map whose keys are **forge aliases** and follow the +manifest's existing kebab-case identifier grammar +(`[a-z][a-z0-9-]*`). Each forge entry contains: - `url`: a canonical HTTPS Gitea API base URL; - `auth.type`: `token` in this slice; - `auth.token_secret`: the name of a host environment variable containing the operator-provided, agent-specific API token. -The token secret name is host configuration, not bottle configuration. The -token value is resolved only if a selected bottle repository references the -account. +The token's owner determines the authenticated forge account; there is no +separate account-name field. The token secret name is host configuration, not +bottle configuration. The token value is resolved only if a selected bottle +repository references the forge alias. ### Bottle manifest @@ -187,7 +190,8 @@ git-gate: - When absent, the repository behaves exactly as it does today. Bot-bottle does not resolve a forge actor token and does not add forge-specific instructions for that repository. -- When present, it must match a `forge-accounts` entry on the selected agent. +- When present, it must match a forge alias in `forge-accounts` on the selected + agent. The resolved association enables a scoped proxy credential route and adds the repository/forge relationship to generated system guidance. @@ -230,7 +234,7 @@ composition; they are not a filesystem discovery path. ### Forge URL validation -The host parses and canonicalizes each referenced forge account URL before +The host parses and canonicalizes each referenced forge alias's URL before creating any runtime resources: - scheme must be `https`; @@ -248,7 +252,7 @@ and guidance renderer. ### Proxy credential provisioning -For each distinct forge account referenced by at least one selected bottle +For each distinct forge alias referenced by at least one selected bottle repository, the host: 1. Resolves `auth.token_secret` from the host environment and rejects a missing @@ -262,8 +266,8 @@ The bottle makes an unauthenticated request to the configured HTTPS API URL. The token is not copied into the bottle, `.gitconfig`, generated prompt, workspace, or process environment visible to the agent. -If several repositories reference the same forge account, they share one -credential route. Unreferenced forge accounts resolve no secret and create no +If several repositories reference the same forge alias, they share one +credential route. Unreferenced forge aliases resolve no secret and create no route. ### Generated system guidance @@ -274,7 +278,7 @@ a repository. For each associated repository, Gitea guidance includes: -- account alias (for example `didericis-gitea`); +- forge alias (for example `didericis-gitea`); - API base URL (for example `https://gitea.dideric.is/api/v1`); - the git-gate repository name tied to that forge; - the instruction to call the configured HTTPS API through the proxy without @@ -300,7 +304,7 @@ The operator-owned token is not minted, rotated, or revoked by bot-bottle. On teardown, stopping the egress proxy discards the activation's in-memory/runtime copy. Later activations resolve the current host secret again. -Logs may include the account alias, canonical API origin, and repository name. +Logs may include the forge alias, canonical API origin, and repository name. They must never contain the token value. Errors for missing secrets name the configuration field and host environment variable, but do not print any value. -- 2.52.0