# 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?