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>
20 KiB
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.username/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.username/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. OnlySSH_AUTH_SOCKcrosses 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-commitlong 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-claudeas 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/tokensrequires Basic Auth as the target user (an admin PAT cannot mint one for another user; the only server-side path is thegitea admin user generate-access-tokenCLI), 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:
- 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.
- 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.
- 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).
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: trueopts a bottle into the feature. Without it, behavior is exactly as today (unsigned, unenforced).git-gate.signing.enforcenames which identity fields the gate matches againstgit-gate.user; it defaults to[author, committer](both).git-gate.signingis bottle-only (it carries enforcement policy, a home-only concern), rejected at the agent level with a clear pointer.git-gate.userkeeps 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-agentholding the short-lived signing private key. -
Only
SSH_AUTH_SOCKis forwarded into the bottle — a bounded signing capability, not the key. -
The provisioner writes the bottle
.gitconfig:[commit] gpgsign = true [gpg] format = ssh [user] name = didericis-claude email = eric+claude@dideric.is signingkey = ssh-ed25519 AAAA... # activation signing PUBLIC key -
git commitasks 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:
- 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. - 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.
- Verify identity fields. For each field named in
signing.enforce(default author and committer), the commit's name and email must equalgit-gate.user. Any mismatch is rejected. - 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.
- 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.
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.gitconfigand thebottled_agent_activationrow (active,valid_fromset). 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
retiredwithvalid_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
- This PRD. Sets the (narrowed) design.
- Manifest surface. Add
git-gate.signing(bottle-only;enabled,enforcewith[author, committer]default); reusegit-gate.useras the enforced identity. Rejectsigningat the agent level. Unit tests for parse/validation and the agent-level rejection. - Signing pipeline. Sidecar
ssh-agentprovisioning; forwardSSH_AUTH_SOCKinto the bottle across docker, smolmachines, macOS-container, and firecracker backends; emit thecommit.gpgsign/gpg.format=ssh/user.signingkeygitconfig. Integration test: a bottle commit isverify-commit-valid and its SHA is unchanged through the gate; the private key is absent from the bottle. - 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. - Audit + verification.
bottled_agent_activation/attributed_committables (PRD 0067 store) retaining full pubkey + fingerprint + principal + validity interval; record on accepted push after signature verification; allowed-signers generation + averify-commithelper that works after teardown. - 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.signingparse/validation matrix; agent-levelsigningrejection;enforcedefault 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
activerow; teardown retires it (valid_untilset) 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
retiredrow and confirmverify-commitstill succeeds for an attributed SHA.
Open questions
authoronly vsauthor+committerenforcement. Resolved to both (issue #423, comment #5556, owner preference);enforcedefaults 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.