docs: narrow PRD to per-bottle signed identity & audit attribution
tracker-policy-pr / check-pr (pull_request) Successful in 14s
tracker-policy-pr / check-pr (pull_request) Successful in 14s
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>
This commit is contained in:
@@ -1,433 +0,0 @@
|
|||||||
# PRD prd-new: Forge subroles — per-bottle subuser identity & vouched commit attribution
|
|
||||||
|
|
||||||
- **Status:** Draft
|
|
||||||
- **Author:** didericis-claude
|
|
||||||
- **Created:** 2026-07-25
|
|
||||||
- **Issue:** #423
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Give each bottled agent a **forge subrole**: a single, per-instance identity on
|
|
||||||
a git forge (Gitea today) with a distinct forge account, a strictly-scoped API
|
|
||||||
token, per-repo push credentials, and — the point of the whole thing —
|
|
||||||
**vouched** commit attribution via SSH commit signing performed in the git-gate
|
|
||||||
trust boundary, outside the bottle. Author name/email, forge account, and
|
|
||||||
signing key are one identity per bottled agent, reused across every repo and
|
|
||||||
every forge that bottle touches. All credential material is minted host-side at
|
|
||||||
spin-up, reaches the sidecar but never the bottle, and is revoked at teardown
|
|
||||||
(PRD 0048 discipline). Old key fingerprints and activation/deactivation cycles
|
|
||||||
are retained on the `bottled_agent` table as a durable audit trail.
|
|
||||||
|
|
||||||
This is the "identity" successor to the two PRDs that set it up:
|
|
||||||
|
|
||||||
- **PRD 0027 (agent git identity, #94)** established that `git-gate.user`
|
|
||||||
name/email is *claimed, not vouched* (ADR 0002) — forgeable, cosmetic, and
|
|
||||||
explicitly deferred the integrity question to "a commit-*signing* concern
|
|
||||||
(SSH/GPG)." This PRD is that signing concern; it makes authorship
|
|
||||||
cryptographically vouched and closes the door ADR 0002 left open.
|
|
||||||
- **PRD 0048 (deploy-key provisioning, #169)** established the host-side
|
|
||||||
provisioner pattern: a developer-held minting credential mints short-lived,
|
|
||||||
per-spin-up, revoked-at-teardown key material that reaches the sidecar but
|
|
||||||
never the bottle. Subrole credentials follow the same lifecycle.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
An agent runs on the developer's own machine and inherits the machine's trust,
|
|
||||||
scoped down per role — a *subrole*, not a new principal. That works locally
|
|
||||||
because the machine is single-tenant. A **git forge is the one shared,
|
|
||||||
multi-user layer** where a distinct identity actually earns its keep, and today
|
|
||||||
bot-bottle has none:
|
|
||||||
|
|
||||||
- **Attribution is unvouched.** A bottle commits under whatever `git-gate.user`
|
|
||||||
name/email the manifest declares, which ADR 0002 accepts as forgeable and
|
|
||||||
cosmetic. On a shared forge there is no way to tell a real commit by the
|
|
||||||
subrole from a spoof, and no tamper-evidence on the history the agent
|
|
||||||
produced.
|
|
||||||
- **There is no independent identity to revoke.** Push happens under the
|
|
||||||
bottle's deploy key (0048), but there is no forge *account* for the subrole
|
|
||||||
whose access can be granted, scoped, and killed without touching the
|
|
||||||
developer's own account.
|
|
||||||
- **Forge API actions borrow the developer's hand.** Commenting on an issue,
|
|
||||||
opening a PR, or labeling is done — if at all — as the developer, not as a
|
|
||||||
scoped subrole with its own least-privilege token.
|
|
||||||
|
|
||||||
The naming convention already in use — `didericis-claude`, `didericis-codex`
|
|
||||||
(prefix = lineage/ownership, suffix = subrole) — anticipates this. The identity
|
|
||||||
must be **keyed to the bottled agent** (the trust/credential boundary), not
|
|
||||||
derived from the provider template; today's provider-shaped names are just the
|
|
||||||
special case where the bottle happens to be one-provider. Nothing should
|
|
||||||
*derive* the forge username from the provider (the current code does not — only
|
|
||||||
the container image tag is provider-derived, which is correct).
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
- **One identity per bottled agent.** A single author name/email, a single
|
|
||||||
forge account per forge, and a single SSH signing key serve every repo and
|
|
||||||
every forge the bottle touches. The manifest cannot express two identities
|
|
||||||
for one bottled agent.
|
|
||||||
- **Vouched attribution.** Commits produced in the bottle carry a valid SSH
|
|
||||||
signature (`git verify-commit` succeeds against the retained public key) with
|
|
||||||
no commit-SHA divergence between agent-space and the signed upstream.
|
|
||||||
- **Private signing key never enters the bottle.** Only a bounded signing
|
|
||||||
capability (a forwarded `ssh-agent` socket) crosses the boundary.
|
|
||||||
- **Scoped, independently-revocable forge access.** The subrole is a strict
|
|
||||||
subset of the developer's forge access: a scoped collaborator/account with a
|
|
||||||
forge-scoped, least-privilege API token (commenting, issues, PRs, labeling)
|
|
||||||
and per-repo push credentials.
|
|
||||||
- **Reprovision-per-activation lifecycle.** Push credentials, API token, and
|
|
||||||
signing key are freshly minted on each activation and revoked at teardown;
|
|
||||||
failure to revoke halts teardown loudly (0048). This avoids credential
|
|
||||||
accumulation across frozen snapshots.
|
|
||||||
- **Durable audit trail.** Each activation/deactivation cycle and every retired
|
|
||||||
key is recorded on the `bottled_agent` table as a **public-key fingerprint
|
|
||||||
only** — never private key material.
|
|
||||||
- **Fail-closed provisioning.** A bottle that declares a forge (via
|
|
||||||
`git-forge` or a `git-gate.repos` entry) with no corresponding
|
|
||||||
`agent.forge-accounts` entry fails provisioning with a clear error rather
|
|
||||||
than launching with a half-configured identity.
|
|
||||||
- **Least-privilege, host-only minting credential.** The credential that mints
|
|
||||||
subrole material can create/delete keys and tokens for owned repos/accounts —
|
|
||||||
*not* act as instance admin — and never enters the bottle.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- **Non-Gitea forges.** GitHub and GitLab both support SSH signing keys and
|
|
||||||
commit-status APIs and are a natural fit, but each is a future
|
|
||||||
`bot_bottle/contrib/<forge>/` sub-package. This PRD ships Gitea only.
|
|
||||||
- **Forge-rendered "Verified" badges.** Deliberately abandoned — see Design.
|
|
||||||
Vouched-ness is carried by local `git verify-commit` plus a durable console
|
|
||||||
audit record and (optionally) a commit-status badge, not by the forge's
|
|
||||||
ephemeral signature UI.
|
|
||||||
- **Design B forge-visible pusher identity.** We keep repo-scoped deploy keys
|
|
||||||
for push *capability* (0048 unchanged) and get vouched *authorship* from
|
|
||||||
signing. Making the forge *pusher* record itself the subrole (subuser-owned
|
|
||||||
push keys, broader account/collaborator minting) is a larger surface deferred
|
|
||||||
until a forge-visible pusher is an actual requirement.
|
|
||||||
- **Dirty-teardown reconciliation.** We assume clean teardown for now. A
|
|
||||||
separate cleanup/sync pass reconciles orphaned forge credentials left by a
|
|
||||||
crash or a frozen-then-discarded snapshot; it is out of scope here.
|
|
||||||
- **Dashboard UI** for listing or revoking orphaned subrole credentials.
|
|
||||||
- **Rotation mid-session.** Credentials live for exactly one
|
|
||||||
activation→teardown cycle, as in 0048.
|
|
||||||
- **Generalizing the minting credential into the full `SecretProvider`
|
|
||||||
(#355).** This PRD consumes the existing provisioned-secret reference shape;
|
|
||||||
it does not build the general provider surface.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Identity model
|
|
||||||
|
|
||||||
An identity is realized per **bottled agent** (an agent definition composed
|
|
||||||
with its sealed bottle). It has three parts, all sharing one lifecycle:
|
|
||||||
|
|
||||||
| Part | Value | Where declared | Scope |
|
|
||||||
|------|-------|----------------|-------|
|
|
||||||
| Author | name + email | agent file (`author`) | commit author string |
|
|
||||||
| Forge account | subuser per forge | agent file (`forge-accounts`) | API token owner, PR/issue/label actor |
|
|
||||||
| Signing key | one SSH (Ed25519) keypair | minted, not declared | signs every commit, all repos/forges |
|
|
||||||
|
|
||||||
The same author string and the same signing key are used for **all** repos and
|
|
||||||
**all** forges the bottle touches — there is exactly one identity per bottled
|
|
||||||
agent. Which forge *account* to act as is per-forge (a bottle may touch more
|
|
||||||
than one forge), so `forge-accounts` is a map.
|
|
||||||
|
|
||||||
### Manifest surface
|
|
||||||
|
|
||||||
The identity splits across the two manifest files along the existing trust
|
|
||||||
boundary (ADR 0002, PRD 0047): **identity claims live in the agent file**
|
|
||||||
(overlayable, cosmetic-until-vouched), **forge connections and credential
|
|
||||||
material live in the bottle file** (home-only, credential-bearing).
|
|
||||||
|
|
||||||
**Agent file** — the identity this bottled agent claims:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
author:
|
|
||||||
name: didericis-claude
|
|
||||||
email: eric+claude@dideric.is
|
|
||||||
forge-accounts:
|
|
||||||
gitea: didericis-claude # forge name → subuser account
|
|
||||||
```
|
|
||||||
|
|
||||||
**Bottle file** — how to reach each forge, and which repos the gate exposes:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
git-forge:
|
|
||||||
gitea: # forge name (referenced by repos + accounts)
|
|
||||||
type: gitea
|
|
||||||
ssh:
|
|
||||||
url: ssh://git@100.78.141.42:30009
|
|
||||||
host_key: "ssh-ed25519 AAAA..."
|
|
||||||
api:
|
|
||||||
url: https://gitea.dideric.is/api/v1
|
|
||||||
auth:
|
|
||||||
scheme: token
|
|
||||||
token_secret:
|
|
||||||
type: provisioned # the subrole's API token, minted per-activation
|
|
||||||
provisioner_secret: GITEA_ADMIN_TOKEN # host-only minting credential
|
|
||||||
git-gate:
|
|
||||||
repos:
|
|
||||||
bot-bottle:
|
|
||||||
type: forge-repo
|
|
||||||
forge: gitea # references git-forge.gitea
|
|
||||||
organization: didericis
|
|
||||||
identity_secret:
|
|
||||||
type: provisioned # per-repo push deploy key, minted per-activation (0048)
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes on the shape:
|
|
||||||
|
|
||||||
- `git-forge.<name>` is a new bottle-only block describing one forge: its SSH
|
|
||||||
endpoint (for push, with a pinned `host_key`) and its API endpoint (for
|
|
||||||
subrole actions and commit-status). `provisioner_secret` names a **host env
|
|
||||||
var** holding the least-privilege minting credential; it is resolved at
|
|
||||||
provision time and never stored in the plan or seen by the bottle (same
|
|
||||||
discipline as `token_env` in 0048).
|
|
||||||
- `token_secret: { type: provisioned }` and `identity_secret: { type:
|
|
||||||
provisioned }` are secret *references*, not secret values. `provisioned`
|
|
||||||
means "bot-bottle mints this at spin-up." This is the same reference shape the
|
|
||||||
SecretProvider work (#355) generalizes; here we implement only the
|
|
||||||
`provisioned` case. A future `type: env` / `type: static` can slot in for
|
|
||||||
operator-supplied material, mirroring 0048's `identity:` escape hatch.
|
|
||||||
- `git-gate.repos.<name>` moves from carrying a raw `url`/`identity` (PRD 0047)
|
|
||||||
to referencing a named forge plus `organization`; the gate derives the push
|
|
||||||
URL from `git-forge.<forge>.ssh.url` + `organization` + repo name. This keeps
|
|
||||||
the forge endpoint declared once and reused. Operator-supplied static repos
|
|
||||||
(0048 `identity:` / 0047 `url:`) remain valid for repos that opt out of the
|
|
||||||
forge-subrole machinery.
|
|
||||||
|
|
||||||
The `author`/`forge-accounts` keys are added to `AGENT_KEYS_OPTIONAL`;
|
|
||||||
`git-forge` is added to `BOTTLE_KEYS`. Whether `author`/`forge-accounts` should
|
|
||||||
nest under `git-gate:` (per 0047's "consolidate all git config under one key")
|
|
||||||
or stay top-level as sketched here is an open question below.
|
|
||||||
|
|
||||||
### Provisioning validation (fail-closed)
|
|
||||||
|
|
||||||
At seal/prepare time, before any container starts:
|
|
||||||
|
|
||||||
1. Every forge referenced by a `git-gate.repos[*].forge` and every key in
|
|
||||||
`git-forge` **must** have a matching entry in the agent's `forge-accounts`.
|
|
||||||
A bottle that declares a forge without an agent account fails with:
|
|
||||||
|
|
||||||
```
|
|
||||||
bottle 'dev' declares forge 'gitea' (git-forge / git-gate.repos['bot-bottle'])
|
|
||||||
but agent 'implementer' has no forge-accounts entry for it. A forge subrole
|
|
||||||
requires a forge account; add `forge-accounts: { gitea: <account> }`.
|
|
||||||
```
|
|
||||||
|
|
||||||
2. `author.name` and `author.email` must both be non-empty when any forge is
|
|
||||||
declared (a vouched identity needs an author string to vouch for).
|
|
||||||
|
|
||||||
3. Each `provisioner_secret` env var must be present in the host environment;
|
|
||||||
absence fails loud rather than silently skipping provisioning.
|
|
||||||
|
|
||||||
### Signing: sign at commit time via a forwarded ssh-agent
|
|
||||||
|
|
||||||
The core constraint (issue #423, comment #4321): signing must happen **in the
|
|
||||||
git-gate trust boundary, not in the bottle**, yet the commit SHA must be final
|
|
||||||
at commit time so agent-space and the signed upstream never diverge.
|
|
||||||
|
|
||||||
Chosen mechanism:
|
|
||||||
|
|
||||||
- The **sidecar** (already the git-gate trust boundary, outside the bottle)
|
|
||||||
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 private key never has a representation inside
|
|
||||||
the bottle's filesystem or memory.
|
|
||||||
- The bottle's `.gitconfig` (written by the provisioner) sets:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[commit]
|
|
||||||
gpgsign = true
|
|
||||||
[gpg]
|
|
||||||
format = ssh
|
|
||||||
[user]
|
|
||||||
signingkey = ssh-ed25519 AAAA... # the subrole signing PUBLIC key
|
|
||||||
```
|
|
||||||
|
|
||||||
- `git commit` in the bottle asks the forwarded agent to sign; the signature is
|
|
||||||
embedded in the commit object at creation. The SHA the agent sees **is** the
|
|
||||||
SHA that reaches the upstream through the gate. No transcoding, no SHA
|
|
||||||
translation table, no divergence.
|
|
||||||
|
|
||||||
`git verify-commit` succeeds anywhere that has the subrole public key, which is
|
|
||||||
retained in the audit record (below).
|
|
||||||
|
|
||||||
### Attribution surface: no forge "Verified" badge
|
|
||||||
|
|
||||||
Forge-rendered "Verified" badges are **intentionally abandoned**. Two reasons,
|
|
||||||
both established in the issue thread:
|
|
||||||
|
|
||||||
1. **They are ephemeral.** Gitea (and the others) render the badge dynamically
|
|
||||||
at display time by matching the commit signature against a *currently
|
|
||||||
registered* signing key. Because subrole signing keys are reprovisioned and
|
|
||||||
revoked every activation cycle, a forge-registered key would show
|
|
||||||
"unverified" as soon as the bottle tears down — the badge would lie about
|
|
||||||
history the moment the session ends.
|
|
||||||
2. **We don't want to register signing keys on the forge at all.** Registering
|
|
||||||
a signing key on Gitea also grants push access (Gitea does not separate
|
|
||||||
signing keys from access keys), coupling a presentation concern to a
|
|
||||||
capability. Push is already handled by scoped deploy keys (0048); the
|
|
||||||
signing key should carry no forge access.
|
|
||||||
|
|
||||||
Instead, vouched-ness is surfaced two durable ways:
|
|
||||||
|
|
||||||
- **Local verification.** `git verify-commit <sha>` against the retained public
|
|
||||||
key is the ground truth and survives independent of any forge.
|
|
||||||
- **Commit-status badge (optional, per-forge).** The orchestrator/console posts
|
|
||||||
a commit *status* via the forge API (all three forges expose this and it
|
|
||||||
persists server-side permanently): `pending` when the commit is observed,
|
|
||||||
`success` at clean teardown, with a `target_url` pointing at the durable
|
|
||||||
console audit record. This is posted using the subrole's API token, so the
|
|
||||||
badge is attributed to the subrole, and it does not depend on any key still
|
|
||||||
being registered.
|
|
||||||
|
|
||||||
### Credential lifecycle
|
|
||||||
|
|
||||||
Follows PRD 0048's mint-at-spin-up / revoke-at-teardown discipline, extended to
|
|
||||||
three credential kinds and made **reprovision-per-activation**:
|
|
||||||
|
|
||||||
**At activation (spin-up), host-side, using each forge's `provisioner_secret`:**
|
|
||||||
|
|
||||||
1. Mint a fresh Ed25519 **signing** keypair. Load the private half into the
|
|
||||||
sidecar `ssh-agent`; write the public half into the bottle `.gitconfig`
|
|
||||||
`user.signingkey` and into the audit record (fingerprint).
|
|
||||||
2. Mint a fresh **API token** for the subrole account, forge-scoped to the
|
|
||||||
minimum: commenting, creating issues, creating PRs, labeling issues.
|
|
||||||
Resolves the `token_secret: { type: provisioned }` reference. Held host/
|
|
||||||
sidecar-side for commit-status and forge actions; never handed to the
|
|
||||||
bottle raw (the bottle reaches the forge API through the gate/broker).
|
|
||||||
3. Mint fresh per-repo **push deploy keys** for every `git-gate.repos` entry
|
|
||||||
whose `identity_secret` is `provisioned` — exactly PRD 0048's
|
|
||||||
`DeployKeyProvisioner.create`.
|
|
||||||
|
|
||||||
Keys are **generated once per activation and persist across restarts** within
|
|
||||||
that activation (a restart re-attaches the same minted material, it does not
|
|
||||||
re-mint). Re-minting happens on a *new* activation. This is deliberate: minting
|
|
||||||
per-restart would pile up dead keys on the forge for every frozen snapshot;
|
|
||||||
minting per-activation keeps exactly one live key set per running bottled agent.
|
|
||||||
|
|
||||||
**At teardown (fail-loud, 0048):**
|
|
||||||
|
|
||||||
- Delete the API token and every provisioned deploy key via the forge API.
|
|
||||||
- Discard the signing key from the sidecar `ssh-agent` (it was never on the
|
|
||||||
forge, so there is nothing to revoke there — but its fingerprint is retired
|
|
||||||
in the audit trail).
|
|
||||||
- Any deletion failure **halts teardown and propagates loudly** (404 =
|
|
||||||
already-gone = success, per 0048).
|
|
||||||
- We **assume clean teardown.** Dirty teardown (crash, discarded frozen
|
|
||||||
snapshot) may orphan forge credentials; a separate cleanup/sync pass
|
|
||||||
reconciles those and is out of scope here.
|
|
||||||
|
|
||||||
### Audit trail on `bottled_agent`
|
|
||||||
|
|
||||||
The host SQLite store (PRD 0067, `~/.bot-bottle/bot-bottle.db`) gains a
|
|
||||||
`bottled_agent`-scoped record of the identity lifecycle. Every activation and
|
|
||||||
deactivation is recorded, and **retired** keys are kept so history stays
|
|
||||||
attributable after rotation. Only **public-key fingerprints** are ever stored —
|
|
||||||
never private key material, never the API token value.
|
|
||||||
|
|
||||||
Illustrative shape (subject to alignment with the forge-orchestration tables
|
|
||||||
0067 anticipated):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE bottled_agent_identity (
|
|
||||||
bottled_agent_slug TEXT NOT NULL,
|
|
||||||
activation_id TEXT NOT NULL, -- one per activation cycle
|
|
||||||
forge TEXT NOT NULL, -- e.g. 'gitea'
|
|
||||||
forge_account TEXT NOT NULL, -- e.g. 'didericis-claude'
|
|
||||||
author_name TEXT NOT NULL,
|
|
||||||
author_email TEXT NOT NULL,
|
|
||||||
signing_key_fpr TEXT NOT NULL, -- SHA256:... public-key fingerprint
|
|
||||||
activated_at TEXT NOT NULL,
|
|
||||||
deactivated_at TEXT, -- NULL while active
|
|
||||||
status TEXT NOT NULL, -- active | retired
|
|
||||||
PRIMARY KEY (bottled_agent_slug, activation_id, forge)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
This table is the `target_url` destination for commit-status badges and the
|
|
||||||
source of truth for "which key signed this commit, and when was that identity
|
|
||||||
live."
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
- **Stateless reversible transcoder (issue #423, comment #4321, Solution 2).**
|
|
||||||
Strip/re-apply the `gpgsig` header on fetch/push with deterministic signing
|
|
||||||
and an in-memory SHA translation table. Rejected: it introduces a SHA
|
|
||||||
divergence between agent-space and remote-space that must be maintained for
|
|
||||||
the life of the session, with no persistence story across restarts.
|
|
||||||
Sign-at-commit-time keeps SHAs identical and needs no translation state.
|
|
||||||
- **Register the signing key on the forge for a "Verified" badge (original
|
|
||||||
issue sketch).** Rejected: badges are rendered dynamically and vanish when
|
|
||||||
the reprovisioned key is revoked; on Gitea a signing key also grants push,
|
|
||||||
coupling presentation to capability. See "Attribution surface" above.
|
|
||||||
- **Design B: subuser-owned push keys / forge pusher = subrole.** Deferred;
|
|
||||||
larger account- and collaborator-management surface with a broader minting
|
|
||||||
token, not required to get vouched authorship.
|
|
||||||
|
|
||||||
## Implementation chunks
|
|
||||||
|
|
||||||
1. **This PRD.** Sets the design.
|
|
||||||
2. **Manifest surface + validation.** Add `author` / `forge-accounts` to agent
|
|
||||||
keys; add `git-forge` to bottle keys; parse the provisioned-secret reference
|
|
||||||
shape; restructure `git-gate.repos` to reference a named forge +
|
|
||||||
organization while keeping 0047/0048 static entries valid. Fail-closed
|
|
||||||
checks (forge-without-account, missing author, missing `provisioner_secret`).
|
|
||||||
Unit tests for each parse/validation path.
|
|
||||||
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
|
|
||||||
`git verify-commit`-valid and its SHA is unchanged through the gate.
|
|
||||||
4. **Forge-account provisioning (Gitea contrib).** Extend
|
|
||||||
`bot_bottle/contrib/gitea/` with API-token minting (scoped: comment, issue,
|
|
||||||
PR, label) and revocation, reusing the `provisioner_secret` custody model.
|
|
||||||
Wire the signing-key and API-token lifecycle into the same
|
|
||||||
activation/teardown hooks as `DeployKeyProvisioner`.
|
|
||||||
5. **Audit + commit-status.** `bottled_agent_identity` table (PRD 0067 store);
|
|
||||||
record activation/deactivation and retired-key fingerprints; post
|
|
||||||
commit-status badges (`pending` → `success`) with `target_url` to the audit
|
|
||||||
record.
|
|
||||||
6. **Docs.** Glossary entry for "Forge Subrole"; README manifest section;
|
|
||||||
ADR update noting authorship is now *vouched* for signed bottled agents
|
|
||||||
(superseding the ADR 0002 posture for this path).
|
|
||||||
|
|
||||||
## Testing strategy
|
|
||||||
|
|
||||||
- **Unit (must):** manifest parse/validation matrix — `author` +
|
|
||||||
`forge-accounts` on the agent; `git-forge` on the bottle; provisioned-secret
|
|
||||||
references; the fail-closed cases (forge without account, empty author,
|
|
||||||
absent `provisioner_secret`); `git-gate.repos` forge-reference resolution
|
|
||||||
alongside surviving 0047/0048 static entries.
|
|
||||||
- **Integration (must):** end-to-end signed commit — bottle commits, the
|
|
||||||
signature verifies with `git verify-commit`, and the SHA observed in the
|
|
||||||
bottle equals the SHA that lands upstream through the gate. Private key is
|
|
||||||
absent from the bottle filesystem/agent-visible memory.
|
|
||||||
- **Lifecycle (must):** activation mints signing key + API token + deploy keys;
|
|
||||||
teardown revokes all three and halts loudly on a forced API failure; the
|
|
||||||
`bottled_agent_identity` row transitions `active` → `retired` with only a
|
|
||||||
fingerprint stored.
|
|
||||||
- **Reprovision-per-activation:** a restart re-attaches the same key set (no new
|
|
||||||
forge key); a fresh activation mints a new set and retires the old
|
|
||||||
fingerprint.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- **Key placement in the manifest.** Do `author` / `forge-accounts` stay
|
|
||||||
top-level on the agent file (as sketched by the owner in #4927), or nest under
|
|
||||||
`git-gate:` to honor PRD 0047's "consolidate all git configuration under one
|
|
||||||
key"? The former reads cleaner; the latter is more consistent. Resolve before
|
|
||||||
chunk 2.
|
|
||||||
- **Where does the commit-status poster run?** The orchestrator/console holds
|
|
||||||
the API token host-side and is the natural poster, but it must observe new
|
|
||||||
commit SHAs. Is that a gate push-hook callback, or a poll? (Ties into the
|
|
||||||
forge-orchestration work 0067 anticipated.)
|
|
||||||
- **Provisioner-secret naming.** `GITEA_ADMIN_TOKEN` reads as instance-admin,
|
|
||||||
but the constraint is least-privilege (create/delete keys + tokens for owned
|
|
||||||
repos/accounts, *not* admin). Rename to `GITEA_MINT_TOKEN` (per the issue
|
|
||||||
body) to avoid implying more privilege than the credential should carry?
|
|
||||||
- **Signing-key fingerprint vs. full pubkey in the audit record.** The thread
|
|
||||||
settled on "public-key fingerprint" for the audit trail, but `git
|
|
||||||
verify-commit` needs the full public key. Store both (full pubkey for
|
|
||||||
verification, fingerprint as the stable handle), or reconstruct verification
|
|
||||||
material elsewhere?
|
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
# 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