docs: narrow PRD to per-bottle signed identity & audit attribution

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 01:41:05 +00:00
committed by didericis
parent 34bb7263fa
commit c62d57d5ac
2 changed files with 400 additions and 433 deletions
-433
View File
@@ -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/<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?
@@ -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
<mallory@example>` 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 <new-tips> --not <all-known-upstream-refs>`. 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
`<principal> <signing_pubkey>` 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.