Files
bot-bottle/docs/prds/prd-new-forge-subroles.md
T
didericis-claude 8b70e3240b
tracker-policy-pr / check-pr (pull_request) Successful in 6s
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 <noreply@anthropic.com>
2026-07-25 22:53:14 +00:00

22 KiB

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/<forge>/ 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:

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:

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.<name> 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.<name> 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.<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: <account> }`.
    
  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:

    [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 <sha> 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):

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 (pendingsuccess) 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 activeretired 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?