Files
bot-bottle/docs/prds/prd-new-signed-commits-attribution.md
T
didericis-claude 245f258f20
tracker-policy-pr / check-pr (pull_request) Successful in 7s
lint / lint (push) Successful in 2m44s
docs: drop author/committer enforcement; anchor audit in control plane
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>
2026-07-25 22:18:48 -04:00

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.user name/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.user name/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. 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 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-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; 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/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 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:

  1. 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.
  2. 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: true opts a bottle in. Without it, behavior is exactly as today. There is no enforce sub-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.signing is bottle-only (home-only policy), 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

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:

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

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. Independently verifies the signature against the activation public key it minted and holds for that activation (via a generated allowed-signers file).
  3. Writes attributed_commit only 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 .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.

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

  1. This PRD. Sets the (narrowed) design.
  2. Manifest surface. Add git-gate.signing (bottle-only; enabled only); reject it 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 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).
  5. Control-plane verification + audit. bottled_agent_activation / attributed_commit tables (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-teardown verify-commit helper. Test that a gateway assertion without an independently-verifiable signature produces no attribution row.
  6. 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.signing parse/validation; agent-level signing rejection.
  • 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 active row; 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 retired row and confirm verify-commit still 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.