WIP: PRD: Per-bottle signed commits & audit attribution #480

Draft
didericis-claude wants to merge 4 commits from prd-forge-subroles into main
Showing only changes of commit 34bb7263fa - Show all commits
+433
View File
@@ -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/<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:
```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.<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:
```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 <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):
```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?