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>
This commit is contained in:
@@ -0,0 +1,420 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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 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.
|
||||||
|
|
||||||
|
```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,
|
||||||
|
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.
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
# 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.
|
|
||||||
Reference in New Issue
Block a user