From 8b70e3240b0a4f439e12b8a29cac0b90226c9859 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 22:53:14 +0000 Subject: [PATCH] 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?