Revised per PR #480 review (#5590 + didericis-codex review on d8362ec):
- Remove author/committer enforcement entirely (#5590). The gate no
longer matches identity fields; author/committer are recorded as
claims in the audit store. Drop the git-gate.signing.enforce knob
(which also resolves codex issue 1: a knob that weakened the stated
guarantee). Add a "Deferred: identity enforcement" section noting it
as a possible future add. Rename PRD/file to "signed commits & audit
attribution" since identity is no longer guaranteed.
- Fix control-plane vs data-plane verification (codex issue 2, PRD 0070):
git-gate (data plane) does a synchronous pre-forward SIGNATURE check
only; the orchestrator/control plane (sole owner of bot-bottle.db)
independently re-verifies each signature before writing attributed_commit.
A gateway assertion alone never creates an audit row. New "Trust
boundary" + "Control-plane verification & recording" sections.
- Reframe the guarantee to signed provenance + host-owned, independently
verified audit record; ADR 0002 "claimed, not vouched" posture kept.
- attributed_commit now records claimed author/committer columns.
Issue: #423
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
22 KiB
PRD prd-new: Per-bottle signed commits & audit attribution
- Status: Draft
- Author: didericis-claude
- Created: 2026-07-25
- Issue: #423
Summary
Give each bottled agent a per-activation signing key so that every commit it
introduces is signed in the git-gate trust boundary (outside the bottle) and
recorded in bot-bottle's own host-owned audit store, which is the portable
source of truth binding each signed commit to a bottle, host, manifest, agent,
activation interval, and retained public key — along with the commit's claimed
author. The gate mints a short-lived Ed25519 key at spin-up, holds the private
half in the sidecar ssh-agent, and forwards only SSH_AUTH_SOCK into the
bottle. The gate rejects any newly-introduced commit that is not signed by the
activation key; the control plane independently re-verifies each signature
before recording attribution.
This PRD deliberately does not enforce or vouch author/committer identity. Author/committer name/email are recorded as claims carried inside the signed object; making the gate reject a mismatching author/committer is a possible future add (see Non-goals and Deferred: identity enforcement). Push capability stays exactly as PRD 0048 deploy keys; forge subuser accounts, provisioned API tokens, and forge-side status/"Verified" badges remain out of scope (a future "forge actors" PRD).
Successor to:
- PRD 0027 (agent git identity, #94) / ADR 0002 — established that
git-gate.username/email is claimed, not vouched. This PRD keeps that posture: it adds signed provenance and a durable host record, not identity enforcement. - PRD 0048 (deploy-key provisioning, #169) — the host-side mint-at-spin-up / revoke-at-teardown lifecycle the signing key follows. Deploy keys are unchanged.
- PRD 0070 (per-host orchestrator, #351) — the orchestrator/control plane is
the sole owner of
bot-bottle.db; audit verification and recording live there, not in the data-plane gate (see Trust boundary).
The guarantee
The crisp property this feature provides:
Every new commit introduced through this bottle's gate is signed by the activation key, and the host-owned audit store — after the control plane independently verifies that signature — records it, binding the SHA to the bottle, host, manifest, agent, activation interval, and retained public key. The commit's author/committer identity is recorded as a claim, not enforced or vouched. The forge remains only the repository transport/capability layer.
What the signature does and does not prove (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. Independent verification binds SHA X to activation Y.
- The signature does not 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 and recorded as-is. - The audit record is a cryptographic binding only because the control plane verifies the signature before recording — not because the data-plane gate asserts it (see Trust boundary).
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:
- No provenance. Nothing ties a pushed commit to the bottle/activation that
actually produced it.
git-gate.username/email is forgeable and cosmetic (ADR 0002); a commit could be produced anywhere. - 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 rejects unsigned commits. Before the gate forwards a push, every newly-introduced commit (those not already reachable from the advertised upstream refs) must verify against the activation public key; a push with any unsigned or wrong-key new commit is rejected, loudly, with the offending SHA. This is a signature check only — no author/committer matching.
- Control-plane-owned attribution. The orchestrator/control plane (sole
owner of
bot-bottle.db, PRD 0070) independently verifies each new commit's signature and only then writes the attribution row. A gateway assertion alone never creates an audit record. - Host is the source of truth. The audit record binds each attributed SHA to the bottle, host, manifest, agent, activation interval, and retained public key, and records the commit's claimed author/committer.
- 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; no new forge API dependency beyond 0048's existing deploy-key registration.
Non-goals
- Author/committer enforcement. Explicitly out of scope for this PRD (issue #423, comment #5590). The gate does not reject a commit for carrying a foreign author or committer; those fields are recorded as claims. We rely on the cross-forge audit store of signed commits and the authors recorded there. See Deferred: identity enforcement for what a future add would look like.
- Cryptographically-vouched author identity. Not claimed — see The guarantee.
- Forge subuser accounts / provisioned 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 design in its own right (issue #423, comments #5518 / #5554). This PRD needs no subrole API token, so that bootstrap problem does not arise. - Forge-side attribution surfaces. No commit-status badges, no forge
"Verified" badge. The latter is doubly unsuitable: it renders 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 → #5590) narrowed it in two steps:
- Dropped the forge-account and API-token machinery (#5518 → #5556): the PAT bootstrap is not implementable as sketched (Basic-Auth-as-target-user constraint); the signature never vouched the author anyway; and making the host audit store the portable source of truth is a cleaner boundary that removes the forge-specific token lifecycle and commit-status dependence.
- Dropped author/committer enforcement (#5590): rely on the audit store of signed commits and the authors recorded there; gate enforcement of the identity fields is a possible future add, not part of this slice.
What remains is the core that stands on its own: signed commits + a host-owned, independently-verified audit record. Forge actors (a per-bottle account that comments/opens PRs) and identity enforcement (the gate rejecting a foreign author/committer) are each candidate future PRDs.
Design
Trust boundary (control plane vs data plane)
git-gate is the data plane: it parses hostile bytes from inside the bottle
and forwards pushes. The orchestrator is the control plane and, per PRD
0070, is the sole owner of bot-bottle.db. These are different trust boundaries,
and the audit record must be anchored in the control plane:
- The gate performs a synchronous pre-forward signature check (below) and can reject a push before it reaches the upstream. This is a data-plane gate, not the audit binding.
- The control plane independently verifies each new commit's signature —
receiving the commit object + signature (or independently reading/fetching the
objects it is asked to attribute) — and only then writes
attributed_commit. A gateway assertion alone never produces a row. If the gateway is compromised, the worst it can do is forward or reject pushes; it cannot fabricate an audit binding, because the control plane re-verifies against the activation public key it minted and holds.
The gate cannot stand in for host-side audit verification (issue #423, review by
didericis-codex on d8362ec).
Identity model
Per bottled agent (agent definition ∘ sealed bottle), realized per activation:
| Part | Value | Source | Role |
|---|---|---|---|
| Signing key | one Ed25519 keypair | minted host-side per activation | signs every commit; private half sidecar-only; the anchor of provenance |
| Author/committer | name + email | git-gate.user (PRD 0027 overlay) |
written into commits and recorded as a claim; not enforced |
Manifest surface
No new top-level keys and no git-forge/forge-accounts blocks. A single
opt-in flag under the existing git-gate key turns on per-activation signing;
git-gate.user (PRD 0027) supplies the author string as today.
git-gate:
user: # PRD 0027 — author string; recorded, not enforced
name: didericis-claude
email: eric+claude@dideric.is
signing:
enabled: true # NEW — opt-in per-activation signing + audit
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 in. Without it, behavior is exactly as today. There is noenforcesub-key — this PRD does not enforce identity fields, so no knob is needed (and a knob that weakened a guarantee was flagged as a contradiction in review).git-gate.signingis bottle-only (home-only policy), 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
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 pre-forward signature check (data plane)
The gate already fetches from upstream before every upload-pack and mirrors
bidirectionally (PRD 0008). When git-gate.signing.enabled is set, after
gitleaks and before forwarding a push upstream:
- Compute the newly-introduced set. 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; 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. A commit that is unsigned or signed by any other key causes the push to be rejected with the offending SHA.
- No author/committer matching is performed.
This is a synchronous safety gate on what leaves the bottle; it is not the audit record.
Control-plane verification & recording
On an accepted push (or asynchronously, driven by the accepted-push event), the orchestrator/control plane:
- Obtains the commit object and its signature for each newly-introduced SHA — either delivered by the gate as opaque bytes to be checked, or read/fetched independently from the mirrored repository. Either way the control plane does not trust a "verified" flag from the gate.
- Independently verifies the signature against the activation public key it minted and holds for that activation (via a generated allowed-signers file).
- Writes
attributed_commitonly for SHAs that pass, recording the commit's claimed author/committer alongside the activation binding.
A SHA the control plane cannot independently verify is not recorded as attributed (it may be logged as an anomaly instead).
Audit trail
The host SQLite store (PRD 0067, ~/.bot-bottle/bot-bottle.db, owned by the
control plane per PRD 0070) records the signing-key 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,
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,
author_name TEXT NOT NULL, -- CLAIMED, recorded as-is (not enforced)
author_email TEXT NOT NULL, -- CLAIMED
committer_name TEXT NOT NULL, -- CLAIMED
committer_email TEXT NOT NULL, -- CLAIMED
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. The
recorded author/committer columns are the claim; a consumer that wants to know
"who says they wrote this" reads them, understanding they are unenforced.
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.
Deferred: identity enforcement
If a future PRD wants the gate to enforce that new commits carry the manifest
identity, the natural shape is: extend the gate pre-forward check to also require
each new commit's author and committer name/email to equal git-gate.user,
rejecting mismatches — with the same control-plane re-verification before
recording. This is deliberately left out now (issue #423, comment #5590); it is
noted so the door stays open and the current schema (which records the claimed
author/committer) already carries what such a check would compare against. Note
that even then the property would be gate-enforced, not signature-vouched; a
validating signing broker in front of the key would be required for the latter.
Implementation chunks
- This PRD. Sets the (narrowed) design.
- Manifest surface. Add
git-gate.signing(bottle-only;enabledonly); reject it at 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 pre-forward signature check. Compute the newly-introduced set (excluding upstream-reachable commits), verify each against the activation key, reject unsigned/wrong-key with the offending SHA. Tests: unsigned rejected; wrong-key rejected; pulled/merged upstream history passes; an all-signed push succeeds. A foreign-author commit that is correctly signed passes the gate (identity is not enforced here).
- Control-plane verification + audit.
bottled_agent_activation/attributed_committables (PRD 0067 store, control-plane-owned per PRD 0070); the control plane independently verifies each new commit's signature before writing a row; retain full pubkey + fingerprint + principal + validity interval; record claimed author/committer; allowed-signers generation + a post-teardownverify-commithelper. Test that a gateway assertion without an independently-verifiable signature produces no attribution row. - Docs. Glossary entry ("per-bottle signed commits"); README manifest section; ADR note that signing-enabled bottles gain signed provenance and a host-owned audit record while authorship stays claimed (ADR 0002 unchanged).
Testing strategy
- Unit (must):
git-gate.signingparse/validation; agent-levelsigningrejection. - 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): unsigned rejected; wrong-key rejected; the upstream-reachable exclusion (pull + merge human history and push a signed merge); a correctly-signed foreign-author commit passes (no identity enforcement); a clean all-signed push succeeds.
- Control plane (must): independent verification records an attribution row for a genuinely-signed SHA; a SHA presented with an invalid/foreign signature (or a bare gateway assertion) produces no row.
- Lifecycle: activation mints the key and writes an
activerow; teardown retires it (valid_until) 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
- Control-plane transport for verification. How does the orchestrator obtain the commit object + signature to verify — pushed to it by the gate as opaque bytes, or read/fetched independently from the mirror? Independent read is the stronger boundary; confirm it composes with the mirror layout (PRD 0008) and the orchestrator's repo access (PRD 0070).
- Where the gate check slots into PRD 0008 ordering. Modeled as a pre-forward step after gitleaks; confirm it composes with the existing access-hook / mirror ordering rather than needing a separate hook.