Files
bot-bottle/docs/prds/prd-new-signed-identity-attribution.md
T
didericis-claude d8362ec6e9
tracker-policy-pr / check-pr (pull_request) Successful in 14s
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>
2026-07-26 01:41:05 +00:00

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.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).

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:

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

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.