Forge subroles: per-bottle subuser identity + signed-commit attribution in manifests #423

Open
opened 2026-07-18 18:36:35 -04:00 by didericis-claude · 20 comments
Collaborator

Summary

Introduce forge subroles — per-bottle subuser identities on the git forge — as a first-class manifest concept, with vouched commit attribution via commit signing done in git-gate's trust boundary (not in the bottle).

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 — forgeable, cosmetic, "if attribution integrity matters, that is a commit-signing concern (SSH/GPG)." This issue is that signing concern.
  • 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 should follow the same lifecycle.

Motivation: an agent is a revocable subrole of the developer

bot-bottle's model is that 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 inheritance works on the local machine because it's single-tenant. A git forge is the one shared, multi-user layer where a distinct identity actually earns its keep, because it's the only way to get:

  • Vouched attribution — a cryptographic signature turns "claimed" authorship (0027) into "verified" on the forge.
  • Independent revocation — kill the subrole's credential/account without touching the developer's own.
  • Scoped access — the subrole is a strict subset of the developer's forge access (collaborator on exactly the repos the bottle needs).

Naming convention in use today: didericis-claude, didericis-codex — prefix encodes lineage/ownership, suffix encodes the subrole. The identity must be keyed to the bottle (the trust/credential boundary), not the provider; today's provider-shaped names are just the special case where bottle == provider. Nothing should derive the forge username from the provider template (the current code does not — only the container image tag is provider-derived, which is correct).

Proposed manifest surface (sketch)

A signing: (and optionally subuser) block alongside the existing provisioned_key: in git-gate, provisioned by the same host-side lifecycle:

git-gate:
  user:                      # PRD 0027 — claimed author name/email
    name: claude
    email: eric+claude@dideric.is
  subrole:                   # NEW — the forge subuser this bottle acts as
    account: didericis-claude
  signing:                   # NEW — vouched attribution
    provider: gitea          # register pubkey with the forge for "Verified"
    format: ssh              # ssh (Ed25519) preferred over gpg
    # key material minted short-lived at spin-up, like provisioned_key,
    # held in the sidecar's ssh-agent, revoked at teardown
  repos:
    bot-bottle:
      url: ssh://git@.../didericis/bot-bottle.git
      provisioned_key:
        provider: gitea
        forge_token_env: GITEA_MINT_TOKEN   # least-privilege, host-only

Design decisions to resolve

  1. Capability vs identity for push (piece 2).

    • Design A (smaller): keep repo-scoped deploy keys for push capability (0048 unchanged); get vouched authorship from signing. Pusher is a deploy key; author is cryptographically the subrole.
    • Design B (fuller): mint per-spin-up subuser-owned SSH keys/tokens; subuser is a scoped collaborator; the forge pusher record is also the subrole. Larger surface (account + collaborator management, broader minting token).
    • Lean: Design A unless forge-visible pusher identity is a requirement.
  2. Signing mechanism (piece 3). Resolved toward signing at commit time via a forwarded ssh-agent so the key never enters the bottle and commit SHAs never diverge — see the detailed comment below.

Constraints / non-goals

  • The minting credential must be least-privilege (create/delete key material for owned repos / subusers — not instance admin) and host-only — it never enters the bottle.
  • Fail-loud teardown (0048 discipline): all minted subrole material — push credential and signing key — is revoked at teardown or teardown halts.
  • Signing key custody: the private signing key must not live in the bottle; only a bounded signing capability may cross the boundary.
  • Out of scope: non-Gitea forges (future contrib sub-packages), dashboard UI for orphaned-credential cleanup.

Positioning note

Signed-per-subrole commits are exactly the tamper-evident attribution the sandbox-landscape research flagged as defensible ground (cf. the OAP cryptographic-audit idea in docs/research/agent-sandbox-landscape.md). This is the concrete mechanism behind "an agent is a revocable subrole of the developer."

## Summary Introduce **forge subroles** — per-bottle subuser identities on the git forge — as a first-class manifest concept, with **vouched** commit attribution via commit signing done in git-gate's trust boundary (not in the bottle). 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* — forgeable, cosmetic, "if attribution integrity matters, that is a commit-*signing* concern (SSH/GPG)." This issue is that signing concern. - **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 should follow the same lifecycle. ## Motivation: an agent is a revocable subrole of the developer bot-bottle's model is that 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 inheritance works on the local machine because it's single-tenant. A **git forge is the one shared, multi-user layer** where a distinct identity actually earns its keep, because it's the only way to get: - **Vouched attribution** — a cryptographic signature turns "claimed" authorship (0027) into "verified" on the forge. - **Independent revocation** — kill the subrole's credential/account without touching the developer's own. - **Scoped access** — the subrole is a strict subset of the developer's forge access (collaborator on exactly the repos the bottle needs). Naming convention in use today: `didericis-claude`, `didericis-codex` — prefix encodes lineage/ownership, suffix encodes the subrole. The identity must be **keyed to the bottle** (the trust/credential boundary), not the provider; today's provider-shaped names are just the special case where bottle == provider. Nothing should *derive* the forge username from the provider template (the current code does not — only the container image tag is provider-derived, which is correct). ## Proposed manifest surface (sketch) A `signing:` (and optionally subuser) block alongside the existing `provisioned_key:` in `git-gate`, provisioned by the same host-side lifecycle: ```yaml git-gate: user: # PRD 0027 — claimed author name/email name: claude email: eric+claude@dideric.is subrole: # NEW — the forge subuser this bottle acts as account: didericis-claude signing: # NEW — vouched attribution provider: gitea # register pubkey with the forge for "Verified" format: ssh # ssh (Ed25519) preferred over gpg # key material minted short-lived at spin-up, like provisioned_key, # held in the sidecar's ssh-agent, revoked at teardown repos: bot-bottle: url: ssh://git@.../didericis/bot-bottle.git provisioned_key: provider: gitea forge_token_env: GITEA_MINT_TOKEN # least-privilege, host-only ``` ## Design decisions to resolve 1. **Capability vs identity for push (piece 2).** - *Design A (smaller):* keep repo-scoped deploy keys for push *capability* (0048 unchanged); get vouched *authorship* from signing. Pusher is a deploy key; author is cryptographically the subrole. - *Design B (fuller):* mint per-spin-up **subuser-owned** SSH keys/tokens; subuser is a scoped collaborator; the forge *pusher* record is also the subrole. Larger surface (account + collaborator management, broader minting token). - Lean: **Design A** unless forge-visible *pusher* identity is a requirement. 2. **Signing mechanism (piece 3).** Resolved toward signing at commit time via a forwarded ssh-agent so the key never enters the bottle and commit SHAs never diverge — see the detailed comment below. ## Constraints / non-goals - The minting credential must be **least-privilege** (create/delete key material for owned repos / subusers — *not* instance admin) and **host-only** — it never enters the bottle. - **Fail-loud teardown** (0048 discipline): all minted subrole material — push credential and signing key — is revoked at teardown or teardown halts. - Signing key custody: the private signing key must **not** live in the bottle; only a bounded signing capability may cross the boundary. - Out of scope: non-Gitea forges (future contrib sub-packages), dashboard UI for orphaned-credential cleanup. ## Positioning note Signed-per-subrole commits are exactly the tamper-evident attribution the sandbox-landscape research flagged as defensible ground (cf. the OAP cryptographic-audit idea in `docs/research/agent-sandbox-landscape.md`). This is the concrete mechanism behind "an agent is a revocable subrole of the developer."
gitea-actions bot added the Status/Needs Triage label 2026-07-18 18:36:42 -04:00
Author
Collaborator

Design discussion: signing commits in git-gate and the SHA-translation problem

Capturing the discussion that led to this issue.

Why sign in the git-gate trust boundary

git-gate is the host-side sidecar that already holds forge credentials and mediates every push — the natural place for the signing key, keeping it completely out of the bottle. A gate/subrole signature attests provenance — "this commit was produced through didericis-claude's bottle, by the holder of the key" — not agent intent (which is unprovable anyway, and which the agent controls regardless). Provenance is the achievable, useful property, and the key living outside the bottle is exactly what makes it trustworthy.

The catch: signing rewrites commit objects → SHAs change

A signature lives inside the commit object (gpgsig header) and the SHA is computed over the whole object. So signing after the agent has committed rewrites every commit's hash and all its descendants. The agent's local repo (call it A-space) and the signed remote (S-space) diverge, which forces git-gate to become a bidirectional SHA translator: advertise A-space to the agent, translate to S-space toward the remote, and re-parent each new push onto already-signed parents. Trivial for one-shot ephemeral bottles; a stateful smart-protocol proxy for long-lived bottles that push repeatedly.

Solution 1 (chosen): sign at commit time via a forwarded agent — git-gate only verifies

Delete the problem by signing one layer earlier. If the signature is embedded when the agent creates the commit, the SHA it computes already includes the signature — local == remote, nothing to translate. git-gate's role shrinks from "rewrite + maintain an A→S map" to "verify the signature is the expected subrole key, reject otherwise" — stateless, on the pack it already inspects.

Sign at commit time without the key in the bottle by reusing the SSH primitive already in the project:

  • Sidecar runs an ssh-agent holding the subrole's short-lived signing key (minted at spin-up like the deploy key, revoked at teardown). Only SSH_AUTH_SOCK is forwarded into the bottle.
  • Provider-injected git config (not from the repo): commit.gpgsign=true, gpg.format=ssh, user.signingkey=<subrole pubkey>.
  • git commit asks the forwarded agent to sign → the private key never enters the bottle, only a signing oracle does.
  • Register the subrole's public key with Gitea → commits show Verified; git-gate additionally rejects unsigned / wrong-key pushes.

The signing-oracle "exposure" (the agent can ask it to sign arbitrary content) is not a real weakening: the claim is provenance — "came from this subrole's bottle" — which stays true, and the agent can neither extract the key nor sign as any other subrole. Same semantics as gate-signing, zero SHA divergence, git-gate stays a gatekeeper instead of a transcoder. SSH + Ed25519 is lighter than GPG and dovetails with the SSH deploy-key machinery.

Solution 2 (fallback): stateless reversible transcoder

Only needed if the bottle must be fully unaware (no in-bottle git config, no forwarded socket, git-gate creates the signatures). Avoid a persisted A→S map by exploiting that both directions are pure functions of commit content:

  • sign(A) → S: add the gpgsig header, recompute SHA.
  • strip(S) → A: remove the our-key gpgsig header, recompute SHA.

With deterministic signing (Ed25519/SSH signatures are deterministic per RFC 8032; the only timestamp in the signed payload is the commit's own committer date, already fixed), strip(sign(A)) == A and sign(strip(S)) == S for commits this gate signed. git-gate becomes a stateless codec: sign-on-push, strip-on-fetch (our-key sigs only — pass foreign/human signatures through untouched, distinguished by key id), translating ref SHAs per-request in memory. Nothing persists across operations, restarts, or bottles. The cost: it's a genuine git-object-rewriting smart-protocol proxy and the have/want translation must be correct — strictly more work than Solution 1.

Decision

Go with Solution 1 (forwarded ssh-agent, sign at commit time, git-gate verifies) unless a hard "bottle must be 100% pristine/unaware" requirement appears — in which case fall back to the deterministic-signing transcoder in Solution 2.

## Design discussion: signing commits in git-gate and the SHA-translation problem Capturing the discussion that led to this issue. ### Why sign in the git-gate trust boundary git-gate is the host-side sidecar that already holds forge credentials and mediates every push — the natural place for the signing key, keeping it completely out of the bottle. A gate/subrole signature attests **provenance** — "this commit was produced through `didericis-claude`'s bottle, by the holder of the key" — *not* agent intent (which is unprovable anyway, and which the agent controls regardless). Provenance is the achievable, useful property, and the key living outside the bottle is exactly what makes it trustworthy. ### The catch: signing rewrites commit objects → SHAs change A signature lives *inside* the commit object (`gpgsig` header) and the SHA is computed over the whole object. So signing *after* the agent has committed rewrites every commit's hash and all its descendants. The agent's local repo (call it A-space) and the signed remote (S-space) diverge, which forces git-gate to become a **bidirectional SHA translator**: advertise A-space to the agent, translate to S-space toward the remote, and re-parent each new push onto already-signed parents. Trivial for one-shot ephemeral bottles; a stateful smart-protocol proxy for long-lived bottles that push repeatedly. ## Solution 1 (chosen): sign at commit time via a forwarded agent — git-gate only verifies **Delete the problem by signing one layer earlier.** If the signature is embedded when the agent *creates* the commit, the SHA it computes already includes the signature — local == remote, nothing to translate. git-gate's role shrinks from "rewrite + maintain an A→S map" to "**verify** the signature is the expected subrole key, reject otherwise" — stateless, on the pack it already inspects. Sign at commit time without the key in the bottle by reusing the SSH primitive already in the project: - Sidecar runs an `ssh-agent` holding the subrole's **short-lived signing key** (minted at spin-up like the deploy key, revoked at teardown). Only `SSH_AUTH_SOCK` is forwarded into the bottle. - Provider-injected git config (not from the repo): `commit.gpgsign=true`, `gpg.format=ssh`, `user.signingkey=<subrole pubkey>`. - `git commit` asks the forwarded agent to sign → **the private key never enters the bottle**, only a signing *oracle* does. - Register the subrole's public key with Gitea → commits show **Verified**; git-gate additionally rejects unsigned / wrong-key pushes. The signing-oracle "exposure" (the agent can ask it to sign arbitrary content) is **not** a real weakening: the claim is provenance — "came from this subrole's bottle" — which stays true, and the agent can neither extract the key nor sign as any *other* subrole. Same semantics as gate-signing, zero SHA divergence, git-gate stays a gatekeeper instead of a transcoder. SSH + Ed25519 is lighter than GPG and dovetails with the SSH deploy-key machinery. ## Solution 2 (fallback): stateless reversible transcoder Only needed if the bottle must be **fully unaware** (no in-bottle git config, no forwarded socket, git-gate creates the signatures). Avoid a *persisted* A→S map by exploiting that both directions are pure functions of commit content: - `sign(A) → S`: add the `gpgsig` header, recompute SHA. - `strip(S) → A`: remove the *our-key* `gpgsig` header, recompute SHA. With **deterministic** signing (Ed25519/SSH signatures are deterministic per RFC 8032; the only timestamp in the signed payload is the commit's own committer date, already fixed), `strip(sign(A)) == A` and `sign(strip(S)) == S` for commits this gate signed. git-gate becomes a **stateless codec**: sign-on-push, strip-on-fetch (our-key sigs only — pass foreign/human signatures through untouched, distinguished by key id), translating ref SHAs per-request in memory. Nothing persists across operations, restarts, or bottles. The cost: it's a genuine git-object-rewriting smart-protocol proxy and the have/want translation must be correct — strictly more work than Solution 1. ## Decision Go with **Solution 1** (forwarded ssh-agent, sign at commit time, git-gate verifies) unless a hard "bottle must be 100% pristine/unaware" requirement appears — in which case fall back to the deterministic-signing transcoder in Solution 2.
didericis-claude added the Kind/FeatureKind/Security
Priority
Medium
3
labels 2026-07-18 18:39:13 -04:00
didericis removed the Status/Needs Triage label 2026-07-18 20:01:04 -04:00
Owner

@didericis-claude think this is a better manifest structure. let’s iterate on it

git-gate:
  forges:
    gitea:
      type: gitea
      url: https://gitea.dideric.is
      api_key: # should have permission to mint keys/upload gpg secrets
        type: env_var
        var: GITEA_ADMIN_TOKEN
  identity:                
    name: claude-implementer
    email: eric+claude-implementer@dideric.is
    forge: ['gitea']
    gpg_secret:
      type: env_var
      var: CLAUDE_IMPLEMENTER

This also folds in changes we'd need to make RE how to pull in secrets from this issue -> #355, so that issue should come first.

@didericis-claude think this is a better manifest structure. let’s iterate on it ```yaml git-gate: forges: gitea: type: gitea url: https://gitea.dideric.is api_key: # should have permission to mint keys/upload gpg secrets type: env_var var: GITEA_ADMIN_TOKEN identity: name: claude-implementer email: eric+claude-implementer@dideric.is forge: ['gitea'] gpg_secret: type: env_var var: CLAUDE_IMPLEMENTER ``` This also folds in changes we'd need to make RE how to pull in secrets from this issue -> https://gitea.dideric.is/didericis/bot-bottle/issues/355, so that issue should come first.
Author
Collaborator

Agreed on iterating — a few observations on the revised structure:

What's better: The forges: named map cleanly separates forge connection config from identity config. Having api_key use type: env_var aligns directly with the SecretProvider shape from #355, so the two issues compose naturally once #355 lands.

Ordering: Makes sense to do #355 first — the type: env_var pattern in both api_key and gpg_secret bottoms out there.

GPG vs SSH: The issue sketch had format: ssh (Ed25519 via forwarded ssh-agent so the key never enters the bottle). This revised manifest switches to gpg_secret passed as a raw env var, which means the private key does cross the boundary. Is that intentional? The ssh-agent path is better custody-wise; GPG-via-env-var is simpler to implement. Worth being explicit about which we're choosing and why, since it affects the security claims in the issue body.

Identity handle: name/email in identity cover the git author fields, but the subrole concept also cares about the forge account (e.g. didericis-claude). Does that come from the name here, or do we need an explicit account: field so git-gate knows which forge user to act as when minting credentials?

forge: ['gitea'] as a list: Nice — multi-forge identity support is a natural extension of the named forges: map.

Agreed on iterating — a few observations on the revised structure: **What's better:** The `forges:` named map cleanly separates forge connection config from identity config. Having `api_key` use `type: env_var` aligns directly with the `SecretProvider` shape from #355, so the two issues compose naturally once #355 lands. **Ordering:** Makes sense to do #355 first — the `type: env_var` pattern in both `api_key` and `gpg_secret` bottoms out there. **GPG vs SSH:** The issue sketch had `format: ssh` (Ed25519 via forwarded ssh-agent so the key never enters the bottle). This revised manifest switches to `gpg_secret` passed as a raw env var, which means the private key _does_ cross the boundary. Is that intentional? The ssh-agent path is better custody-wise; GPG-via-env-var is simpler to implement. Worth being explicit about which we're choosing and why, since it affects the security claims in the issue body. **Identity handle:** `name`/`email` in `identity` cover the git author fields, but the subrole concept also cares about the _forge account_ (e.g. `didericis-claude`). Does that come from the name here, or do we need an explicit `account:` field so git-gate knows which forge user to act as when minting credentials? **`forge: ['gitea']` as a list:** Nice — multi-forge identity support is a natural extension of the named `forges:` map.
Owner

@didericis-claude signing it via an ssh key makes sense, I just want to make sure the name indicates we're using the gpg protocol to do the actual signing. Correct me if we're NOT using gpg/it's incorrect to call it "gpg signing" if we're using an ssh key. Also do a survey and tell me if github, gitea and gitlab all support attaching public ssh keys to accounts to tie commit signatures to accounts. Would be ideal if those keys also get uploaded/attached to their respective forge accounts.

@didericis-claude signing it via an ssh key makes sense, I just want to make sure the name indicates we're using the gpg protocol to do the actual signing. Correct me if we're NOT using gpg/it's incorrect to call it "gpg signing" if we're using an ssh key. Also do a survey and tell me if github, gitea and gitlab all support attaching public ssh keys to accounts to tie commit signatures to accounts. Would be ideal if those keys also get uploaded/attached to their respective forge accounts.
Author
Collaborator

GPG vs SSH terminology: When signing with an SSH key, Git calls it SSH signing — not GPG signing. Git's config namespace is gpg.format = ssh (legacy naming from when GPG was the only option), but the underlying protocol is SSH, not OpenPGP/GPG. So gpg_secret would be misleading in the manifest; something like signing_key with an implied or explicit format: ssh is more accurate.

Forge survey — all three support it:

  • GitHub — upload an SSH public key to your account, mark it as a Signing Key (distinct from auth keys). Signed commits show Verified.
  • GitLab — upload an SSH key with usage type Authentication & Signing or Signing. Signed commits show Verified when the committer email matches a verified email on the account.
  • Gitea (since v1.16) — supports SSH signing, but with one notable difference: Gitea doesn't distinguish signing keys from access keys. A key uploaded to Gitea for signing is also usable for push/pull access. GitHub and GitLab have separate signing-key types; Gitea does not.

The Gitea limitation matters for our design: if we mint a short-lived signing key and upload it to the forge account at spin-up, on Gitea that same key would also grant repo push access (duplicating the deploy key). We'd want to account for that — either accept the overlap, or document it as a Gitea-specific caveat vs. the GitHub/GitLab path.

**GPG vs SSH terminology:** When signing with an SSH key, Git calls it **SSH signing** — not GPG signing. Git's config namespace is `gpg.format = ssh` (legacy naming from when GPG was the only option), but the underlying protocol is SSH, not OpenPGP/GPG. So `gpg_secret` would be misleading in the manifest; something like `signing_key` with an implied or explicit `format: ssh` is more accurate. **Forge survey — all three support it:** - **GitHub** ✅ — upload an SSH public key to your account, mark it as a *Signing Key* (distinct from auth keys). Signed commits show *Verified*. - **GitLab** ✅ — upload an SSH key with usage type *Authentication & Signing* or *Signing*. Signed commits show *Verified* when the committer email matches a verified email on the account. - **Gitea** ✅ (since v1.16) — supports SSH signing, but with one notable difference: **Gitea doesn't distinguish signing keys from access keys**. A key uploaded to Gitea for signing is also usable for push/pull access. GitHub and GitLab have separate signing-key types; Gitea does not. The Gitea limitation matters for our design: if we mint a short-lived signing key and upload it to the forge account at spin-up, on Gitea that same key would also grant repo push access (duplicating the deploy key). We'd want to account for that — either accept the overlap, or document it as a Gitea-specific caveat vs. the GitHub/GitLab path.
Owner

@didericis-claude follow up questions:

  • do any of those forge providers allow you to upload multiple signing keys?
  • if so, are any signing keys assignable to "tags"?

I'm thinking we may want to just skip uploading these to forge, and instead should create these keys per bottle as originally proposed and store their historical usage/which manifests they were associated with in the console. My original idea was to have a 1-1 association between an identity (which has a single account on a forge, and should be thought of as a "sub-role" of the user) and a key, but it seems like it makes more sense to have a 1-1 association between a bottle-instance and a key, which is then associated with an identity.

@didericis-claude follow up questions: - do any of those forge providers allow you to upload multiple signing keys? - if so, are any signing keys assignable to "tags"? I'm thinking we may want to just skip uploading these to forge, and instead should create these keys per bottle as originally proposed and store their historical usage/which manifests they were associated with in the console. My original idea was to have a 1-1 association between an *identity* (which has a single account on a forge, and should be thought of as a "sub-role" of the user) and a key, but it seems like it makes more sense to have a 1-1 association between a *bottle-instance* and a key, which is then *associated* with an identity.
Author
Collaborator

Multiple signing keys per account: Yes — all three support it. GitHub exposes a full REST API for it (GET/POST/DELETE /user/ssh_signing_keys), GitLab and Gitea similarly allow multiple keys with a title per key. No hard documented cap on GitHub's end.

Assignable tags: No — none of the three have structured tagging or labeling beyond a freeform title string on each key. If we needed to encode bottle-instance identity on the forge side, it would have to live in the title (e.g. bottle-abc123-2026-07-20), which is fragile and not queryable.

On the design shift: The per-bottle-instance key model makes more sense. A few thoughts:

  • The audit trail in the console is actually richer than what the forge's "Verified" badge gives you. The forge can only say "this commit was signed by a key belonging to account X." The console can say "this commit was signed by key K, minted for bottle-instance B, running manifest M." That's the attribution story that actually matters.

  • If "Verified" badges on the forge are desirable, it's still achievable with per-instance keys: upload the public key to the forge subrole account at spin-up (GitHub/GitLab have APIs for this), get "Verified" during the bottle's lifetime, then revoke at teardown. The forge account stays clean — zero lingering keys after teardown. But this requires broader minting-token permissions and the Gitea access-key-conflation problem from before.

  • If "Verified" badges are not a hard requirement, skipping the forge upload entirely is simpler and sidesteps the Gitea limitation entirely. The console becomes the authoritative audit log.

I'd lean toward making forge upload optional at the identity level — skip it by default, enable it when the forge account and minting token are configured. That way the simpler path works out of the box and the Verified-badge path is available for setups that want it.

**Multiple signing keys per account:** Yes — all three support it. GitHub exposes a full REST API for it (`GET/POST/DELETE /user/ssh_signing_keys`), GitLab and Gitea similarly allow multiple keys with a title per key. No hard documented cap on GitHub's end. **Assignable tags:** No — none of the three have structured tagging or labeling beyond a freeform `title` string on each key. If we needed to encode bottle-instance identity on the forge side, it would have to live in the title (e.g. `bottle-abc123-2026-07-20`), which is fragile and not queryable. **On the design shift:** The per-bottle-instance key model makes more sense. A few thoughts: - The audit trail in the console is actually *richer* than what the forge's "Verified" badge gives you. The forge can only say "this commit was signed by a key belonging to account X." The console can say "this commit was signed by key K, minted for bottle-instance B, running manifest M." That's the attribution story that actually matters. - If "Verified" badges on the forge are desirable, it's still achievable with per-instance keys: upload the public key to the forge subrole account at spin-up (GitHub/GitLab have APIs for this), get "Verified" during the bottle's lifetime, then revoke at teardown. The forge account stays clean — zero lingering keys after teardown. But this requires broader minting-token permissions and the Gitea access-key-conflation problem from before. - If "Verified" badges are not a hard requirement, skipping the forge upload entirely is simpler and sidesteps the Gitea limitation entirely. The console becomes the authoritative audit log. I'd lean toward making forge upload optional at the identity level — skip it by default, enable it when the forge account and minting token are configured. That way the simpler path works out of the box and the Verified-badge path is available for setups that want it.
Owner

@didericis-claude RE the verified badge for gitea specifically, 1) does gitea say "verified" as well 2) does gitea still say "verified" when we remove the key? Or does that badge get removed?

@didericis-claude RE the verified badge for gitea specifically, 1) does gitea say "verified" as well 2) does gitea still say "verified" when we remove the key? Or does that badge get removed?
Author
Collaborator

Does Gitea say "Verified"? It's icon-based rather than a text label — a green padlock icon vs. a grey/red unlocked icon. Same concept, different presentation from GitHub/GitLab's "Verified" text badge.

Does it persist after key removal? Almost certainly not. Gitea resolves commit verification dynamically at display time by looking up the key in its database. If the key isn't there, the commit shows as "No known key found" (grey icon). This appears to be true of GitHub and GitLab as well — they need the public key on the account to verify the signature at render time.

This actually strengthens the case for skipping forge upload entirely. If we mint per-bottle keys and revoke them at teardown, the "Verified" indication disappears from all past commits on teardown anyway — so we'd be adding forge account management complexity in exchange for a badge that only exists while the bottle is running, not for the full commit history. The console audit trail is the only thing that gives durable, post-teardown attribution regardless of which approach we take.

**Does Gitea say "Verified"?** It's icon-based rather than a text label — a green padlock icon vs. a grey/red unlocked icon. Same concept, different presentation from GitHub/GitLab's "Verified" text badge. **Does it persist after key removal?** Almost certainly not. Gitea resolves commit verification dynamically at display time by looking up the key in its database. If the key isn't there, the commit shows as "No known key found" (grey icon). This appears to be true of GitHub and GitLab as well — they need the public key on the account to verify the signature at render time. This actually strengthens the case for skipping forge upload entirely. If we mint per-bottle keys and revoke them at teardown, the "Verified" indication disappears from all past commits on teardown anyway — so we'd be adding forge account management complexity in exchange for a badge that only exists while the bottle is running, not for the full commit history. The console audit trail is the only thing that gives durable, post-teardown attribution regardless of which approach we take.
Owner

@didericis-claude would be ideal if we could add a badge to commits based on data on the console via some sort of integration. Can you survey the different forge options and let me know if that's viable.

@didericis-claude would be ideal if we could add a badge to commits based on data on the console via some sort of integration. Can you survey the different forge options and let me know if that's viable.
Author
Collaborator

Yes, and it's actually the cleanest path we have — no plugin needed. All three forges expose a commit status API that lets any external service attach a badge to a specific commit SHA:

  • Gitea: POST /api/v1/repos/{owner}/{repo}/statuses/{sha}
  • GitHub: POST /repos/{owner}/{repo}/statuses/{sha}
  • GitLab: POST /projects/{id}/statuses/{sha}

This is the same mechanism CI systems use to post build results. The payload takes a state (pending/success/failure/error), a context string (e.g. bot-bottle), a human-readable description, and crucially a target_url — which is where we'd link back to the console's audit page for that bottle run.

The console workflow would look like:

  1. Bottle makes a commit → console posts pending status to that SHA
  2. Bottle tears down cleanly → console posts success with a link to the run's audit record
  3. If anything is wrong (tampered commit, abnormal teardown) → failure

This is strictly better than what we were discussing with SSH signing badges:

  • The status persists permanently on the forge — it's stored server-side, not derived from the key database at render time
  • It shows on commit pages and on any PR containing that commit
  • The target_url gives a direct link to the console's full attribution record (bottle instance, manifest, key)
  • No forge account key management needed

The only requirement on the forge side is an API token with commit status write permission for the relevant repos — which the identity's api_key in the manifest already covers.

Yes, and it's actually the cleanest path we have — no plugin needed. All three forges expose a **commit status API** that lets any external service attach a badge to a specific commit SHA: - **Gitea**: `POST /api/v1/repos/{owner}/{repo}/statuses/{sha}` - **GitHub**: `POST /repos/{owner}/{repo}/statuses/{sha}` - **GitLab**: `POST /projects/{id}/statuses/{sha}` This is the same mechanism CI systems use to post build results. The payload takes a `state` (pending/success/failure/error), a `context` string (e.g. `bot-bottle`), a human-readable `description`, and crucially a `target_url` — which is where we'd link back to the console's audit page for that bottle run. The console workflow would look like: 1. Bottle makes a commit → console posts `pending` status to that SHA 2. Bottle tears down cleanly → console posts `success` with a link to the run's audit record 3. If anything is wrong (tampered commit, abnormal teardown) → `failure` This is strictly better than what we were discussing with SSH signing badges: - The status persists permanently on the forge — it's stored server-side, not derived from the key database at render time - It shows on commit pages and on any PR containing that commit - The `target_url` gives a direct link to the console's full attribution record (bottle instance, manifest, key) - No forge account key management needed The only requirement on the forge side is an API token with commit status write permission for the relevant repos — which the identity's `api_key` in the manifest already covers.
Owner

@didericis-claude need to tighten up the manifest a bit more:

agent:
  author:
    name: didericis-claude
    email: eric+claude@dideric.is
  forge-accounts:
    gitea: didericis-claude
bottle:
  git-forge:
    gitea:
      type: gitea
      ssh:        
        url: ssh://git@100.78.141.42:30009
        host_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDBVYLbjVAnaXuL7c0UHRSIoqbNoLq8yejrWvDqqXBt5rUGVoZmEZgBLQ34vCihJKz3GyD8piSy5Y/u5XXPCXz9dkvqsF1022Un3DKzqqqXGsc4ZVxPaybNgfj0KuNjBbxjvQnzEuPA1wXK92C1osppyMcqws5/Tz0ylHuRFcS0z1BAG98Dc3EU/g7OtuDqm1KFB+Rvwfz1uGbv7ebehRnkWNoERTXnK3T3I9hEXxU+cy5/mHXpHxbUoRVsZPIvlz6OvdeD47YYvxcVh45zUArsnNdDL0qfWyB1ixL8HE4w0Ds5eFZDf1Ug3FE8Zd1NLd2T8XMs5c8nhanHSXZ4Ra8z/KK0snvMFVz7i2K4lCzXa3bpPCEbLc0Iz/Nrorr4afE2c+1ckD+QbD5kIhiemBFLfarjVEeL3oJ1ts82lfeD2HpP1UxDVpWx4hiwHe5fHlZqRkidIORJRy20LGJL6x5asLwcSVuUxUN5LAWDM41Dj3r1oR8eiUmbftFKuCOA4r6mo2YhHcEXqAqUWgTqu1U/R7mrX/Dit2rjwbvYnwR3FpR+Xstb7o8RxRCXWPDkk1atCmsS09Lp0hRuxi1aWpeUPRwojd2lV4Nn0xubuILHl0wgpruaVlO/JPjpcnyxhI0HJHCIkHED4sxjDAYvnqDXQF/IFIFzrSWxbT/XNEGcCw=="
      api:
        url: https://gitea.dideric.is/api/v1
        auth:
          sheme: token
          token_secret:
            type: provisioned
        provisioner_secret: GITEA_ADMIN_TOKEN
  git-gate:
    repos:
      bot-bottle:
        type: forge-repo
        forge: gitea
        organization: didericis
        identity_secret:
          type: provisioned
  • want to enforce 1 identity per bottled agent
    • the "author" is the git username and email. Ideally it gets set in git-gate so the agent can't spoof it. If that's non trivial we can just keep it in the agent container
    • when spinning up a bottled agent, a new ssh key is generated and attached to the db entry for that bottled agent. This key is then used to sign all commits from that bottled agent
      • this will require storing bottled agent state and bottled agent events in the db
    • this same git author and ssh signing key is used for all repos and all forges
      • the ssh signing key for the commit is the source of truth: commits get signed via the ssh key regardless of other config
    • declared forge accounts are dictated by the agent definition, not the bottle definition. But the bottle provides the actual location
      • if an agent declares a forge the bottle doesn't define, ignore it
      • agent forge usernames get looked up when provisioning api keys
  • want to consolidate forge definitions
    • these can be defined in bottles, and we can define multiple
    • we want to add a provisioned token for commenting on prs as well (lifecycle should be pretty much the to existing deploy key provisioner)
  • more uniform secret parsing
    • essentially we want to do part of this first.
    • when the secret type is "provisioned", the specific provisioner should be dictated by the place in which we're parsing (example: since identity_secret lives in a forge-repo definition, it should use the deploy key provisioner for that given forge. since token_secret lives in a api.auth definition for a forge, it should use a new forge api key provisioner)
@didericis-claude need to tighten up the manifest a bit more: ```yaml agent: author: name: didericis-claude email: eric+claude@dideric.is forge-accounts: gitea: didericis-claude bottle: git-forge: gitea: type: gitea ssh: url: ssh://git@100.78.141.42:30009 host_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDBVYLbjVAnaXuL7c0UHRSIoqbNoLq8yejrWvDqqXBt5rUGVoZmEZgBLQ34vCihJKz3GyD8piSy5Y/u5XXPCXz9dkvqsF1022Un3DKzqqqXGsc4ZVxPaybNgfj0KuNjBbxjvQnzEuPA1wXK92C1osppyMcqws5/Tz0ylHuRFcS0z1BAG98Dc3EU/g7OtuDqm1KFB+Rvwfz1uGbv7ebehRnkWNoERTXnK3T3I9hEXxU+cy5/mHXpHxbUoRVsZPIvlz6OvdeD47YYvxcVh45zUArsnNdDL0qfWyB1ixL8HE4w0Ds5eFZDf1Ug3FE8Zd1NLd2T8XMs5c8nhanHSXZ4Ra8z/KK0snvMFVz7i2K4lCzXa3bpPCEbLc0Iz/Nrorr4afE2c+1ckD+QbD5kIhiemBFLfarjVEeL3oJ1ts82lfeD2HpP1UxDVpWx4hiwHe5fHlZqRkidIORJRy20LGJL6x5asLwcSVuUxUN5LAWDM41Dj3r1oR8eiUmbftFKuCOA4r6mo2YhHcEXqAqUWgTqu1U/R7mrX/Dit2rjwbvYnwR3FpR+Xstb7o8RxRCXWPDkk1atCmsS09Lp0hRuxi1aWpeUPRwojd2lV4Nn0xubuILHl0wgpruaVlO/JPjpcnyxhI0HJHCIkHED4sxjDAYvnqDXQF/IFIFzrSWxbT/XNEGcCw==" api: url: https://gitea.dideric.is/api/v1 auth: sheme: token token_secret: type: provisioned provisioner_secret: GITEA_ADMIN_TOKEN git-gate: repos: bot-bottle: type: forge-repo forge: gitea organization: didericis identity_secret: type: provisioned ``` - want to enforce 1 identity per bottled agent - the "author" is the git username and email. Ideally it gets set in git-gate so the agent can't spoof it. If that's non trivial we can just keep it in the agent container - when spinning up a bottled agent, a new ssh key is generated and attached to the db entry for that bottled agent. This key is then used to sign all commits from that bottled agent - this will require storing bottled agent state and bottled agent events in the db - this same git author and ssh signing key is used for all repos and all forges - the ssh signing key for the commit is the source of truth: commits get signed via the ssh key regardless of other config - declared forge accounts are dictated by the agent definition, not the bottle definition. But the bottle provides the actual location - if an agent declares a forge the bottle doesn't define, ignore it - agent forge usernames get looked up when provisioning api keys - want to consolidate forge definitions - these can be defined in bottles, and we can define multiple - we want to add a provisioned token for commenting on prs as well (lifecycle should be pretty much the to existing deploy key provisioner) - more uniform secret parsing - essentially we want to do part of [this](https://gitea.dideric.is/didericis/bot-bottle/issues/355) first. - when the secret type is "provisioned", the specific provisioner should be dictated by the place in which we're parsing (example: since `identity_secret` lives in a `forge-repo` definition, it should use the deploy key provisioner for that given forge. since `token_secret` lives in a `api.auth` definition for a forge, it should use a new forge api key provisioner)
Author
Collaborator

The single-identity-per-instance model makes sense, especially since the key doubles as both the deploy key credential and the signing key. A few things I want to make sure I understand correctly:

Key lifecycle: Is the SSH key generated once at first spin-up and then persisted in the DB for the lifetime of that agent identity (surviving restarts), or generated fresh each spin-up and stored only for that session's teardown cleanup? The former seems implied by "storing bottled agent state" — if it regenerates on each spin-up, the forge would need a new deploy key registered each time.

Signing key registration: Since Gitea does not distinguish signing keys from access keys, the same SSH public key registered as a deploy key would also be used for commit signing. Does that work as intended, or does the signing key need to be added separately via the forge user account's SSH keys endpoint rather than the repo deploy key endpoint?

Silently ignoring undeclared accounts: "Declared accounts not in bottle config are ignored" — should this be a loud warning at provisioning time? If an agent declares forge-accounts: gitea: didericis-claude but the bottle has no gitea forge config, the agent would silently lose forge access. That seems worth surfacing in the provisioning log.

API key scope: Deploy keys are repo-scoped. Is the API token (for token_secret in forge api.auth) forge-scoped — one token per forge — or repo-scoped? The PR commenting use-case suggests forge-scoped, since you would want one token that can comment across all repos the agent works with.

The context-dependent provisioner approach (inferring provisioner type from position in the manifest) is clean — it keeps the manifest simple without requiring authors to repeat provisioner type in every secret block.

The single-identity-per-instance model makes sense, especially since the key doubles as both the deploy key credential and the signing key. A few things I want to make sure I understand correctly: **Key lifecycle**: Is the SSH key generated once at first spin-up and then persisted in the DB for the lifetime of that agent identity (surviving restarts), or generated fresh each spin-up and stored only for that session's teardown cleanup? The former seems implied by "storing bottled agent state" — if it regenerates on each spin-up, the forge would need a new deploy key registered each time. **Signing key registration**: Since Gitea does not distinguish signing keys from access keys, the same SSH public key registered as a deploy key would also be used for commit signing. Does that work as intended, or does the signing key need to be added separately via the forge user account's SSH keys endpoint rather than the repo deploy key endpoint? **Silently ignoring undeclared accounts**: "Declared accounts not in bottle config are ignored" — should this be a loud warning at provisioning time? If an agent declares `forge-accounts: gitea: didericis-claude` but the bottle has no `gitea` forge config, the agent would silently lose forge access. That seems worth surfacing in the provisioning log. **API key scope**: Deploy keys are repo-scoped. Is the API token (for `token_secret` in forge `api.auth`) forge-scoped — one token per forge — or repo-scoped? The PR commenting use-case suggests forge-scoped, since you would want one token that can comment across all repos the agent works with. The context-dependent provisioner approach (inferring provisioner type from position in the manifest) is clean — it keeps the manifest simple without requiring authors to repeat provisioner type in every secret block.
Owner

@didericis-claude

Key lifecycle: generated once at spin up then persisted/survives restarts.

Signing key registration: do NOT register the signing key via the forge (we're abandoning that). Signing key will be managed via bot-bottle console (and we'll eventually add bot-bottle commit status badges)

Silently ignoring undeclared accounts: a warning would be good, but should go in the other direction. if a bottle includes a forge which the agent identity does not have an account for, we should fail. This works better for composability.

API key scope: api token scopes should be forge scoped, and only allow permissions for commenting, creating issues, creating PRs, and labelling issues (specifics will likely vary between different forges)

@didericis-claude Key lifecycle: generated once at spin up then persisted/survives restarts. Signing key registration: do NOT register the signing key via the forge (we're abandoning that). Signing key will be managed via bot-bottle console (and we'll eventually add bot-bottle commit status badges) Silently ignoring undeclared accounts: a warning would be good, but should go in the other direction. if a bottle includes a forge which the agent identity does not have an account for, we should fail. This works better for composability. API key scope: api token scopes should be forge scoped, and only allow permissions for commenting, creating issues, creating PRs, and labelling issues (specifics will likely vary between different forges)
Author
Collaborator

Got it on all four. A few follow-ons:

Key lifecycle + teardown: With the key persisting across restarts, I want to confirm teardown discipline still applies — the deploy key gets de-registered and the API token revoked when the agent identity is retired, not just when the bottle session ends?

Signing key abandonment: To make sure I understand the new model — git commits are still SSH-signed (so git verify-commit works locally), but the forge-side "Verified" badge is intentionally absent because we are not registering the key there. Attribution is bot-bottle-native via commit status badges rather than forge-native verification. That is the cleaner approach I was hoping for back in #4400.

Bottle forge → fail if no agent account: That composability framing makes more sense. Bottle config is the source of truth for what forges are available; agent identity must cover all of them or provisioning fails. No partial setups silently degrading.

API token scope: Makes sense to keep it narrow. One thing to flag: some forges bundle read and write permissions together in their token scopes (GitHub classic tokens have no read-only issue scope). For Gitea specifically, the issues scope covers both read and write — worth confirming the provisioner always requests the tightest scope each forge actually allows rather than requesting a broader scope for convenience.

Got it on all four. A few follow-ons: **Key lifecycle + teardown**: With the key persisting across restarts, I want to confirm teardown discipline still applies — the deploy key gets de-registered and the API token revoked when the agent identity is retired, not just when the bottle session ends? **Signing key abandonment**: To make sure I understand the new model — git commits are still SSH-signed (so `git verify-commit` works locally), but the forge-side "Verified" badge is intentionally absent because we are not registering the key there. Attribution is bot-bottle-native via commit status badges rather than forge-native verification. That is the cleaner approach I was hoping for back in #4400. **Bottle forge → fail if no agent account**: That composability framing makes more sense. Bottle config is the source of truth for what forges are available; agent identity must cover all of them or provisioning fails. No partial setups silently degrading. **API token scope**: Makes sense to keep it narrow. One thing to flag: some forges bundle read and write permissions together in their token scopes (GitHub classic tokens have no read-only issue scope). For Gitea specifically, the `issues` scope covers both read and write — worth confirming the provisioner always requests the tightest scope each forge actually allows rather than requesting a broader scope for convenience.
Owner

@didericis-claude

Key lifecycle + teardown: good point. Think it's actually worth reprovisioning api keys and deploy keys on each activation instead, avoids issue of having a lot of keys accumulated for frozen snapshots. However, we still want to store the OLD keys on the bottled_agent table, and keep an audit record of agents that were spun up and shut down/what keys they had when they were alive.

Signing key abandonment: correct

Bottle forge → fail if no agent account: agreed

API token scope: agreed

@didericis-claude Key lifecycle + teardown: good point. Think it's actually worth reprovisioning api keys and deploy keys on each activation instead, avoids issue of having a lot of keys accumulated for frozen snapshots. However, we still want to store the OLD keys on the `bottled_agent` table, and keep an audit record of agents that were spun up and shut down/what keys they had when they were alive. Signing key abandonment: correct Bottle forge → fail if no agent account: agreed API token scope: agreed
Author
Collaborator

Makes sense — re-provisioning on each activation keeps the key surface clean (frozen snapshots do not accumulate live credentials) and teardown discipline becomes per-session rather than per-identity-lifetime.

Two things to clarify:

Dirty teardown on re-activation: if a previous session was not cleanly torn down before the next activation, does activation proactively revoke the prior session's keys before minting new ones, or does it assume teardown already handled it?

Audit record contents: I assume bottled_agent stores the public key fingerprint rather than private key material. Worth being explicit about that in the schema so there is no ambiguity about what gets persisted.

Makes sense — re-provisioning on each activation keeps the key surface clean (frozen snapshots do not accumulate live credentials) and teardown discipline becomes per-session rather than per-identity-lifetime. Two things to clarify: **Dirty teardown on re-activation**: if a previous session was not cleanly torn down before the next activation, does activation proactively revoke the prior session's keys before minting new ones, or does it assume teardown already handled it? **Audit record contents**: I assume `bottled_agent` stores the public key fingerprint rather than private key material. Worth being explicit about that in the schema so there is no ambiguity about what gets persisted.
Owner

@didericis-claude

Dirty teardown on re-activation: let's assume it was already handled for now. We'll do a separate clean up/sync pass.

Audit record contents: yes, only the public key fingerprint.

@didericis-claude Dirty teardown on re-activation: let's assume it was already handled for now. We'll do a separate clean up/sync pass. Audit record contents: yes, only the public key fingerprint.
Author
Collaborator

Got it. Assuming clean teardown for now and handling the sync/cleanup edge case separately makes sense — no need to complicate activation with recovery logic until the happy path is solid.

I think that covers everything I had open. The design feels settled: reprovision on each activation, revoke on teardown, public key fingerprint in the audit record, forge-scoped API tokens with minimal permissions, and bot-bottle commit status badges as the attribution story instead of forge-native verification. Ready to move toward a PRD when you are.

Got it. Assuming clean teardown for now and handling the sync/cleanup edge case separately makes sense — no need to complicate activation with recovery logic until the happy path is solid. I think that covers everything I had open. The design feels settled: reprovision on each activation, revoke on teardown, public key fingerprint in the audit record, forge-scoped API tokens with minimal permissions, and bot-bottle commit status badges as the attribution story instead of forge-native verification. Ready to move toward a PRD when you are.
Owner

@didericis-claude go ahead and create a PRD only PR for me to review

@didericis-claude go ahead and create a PRD only PR for me to review
didericis added
Priority
Critical
1
and removed
Priority
Medium
3
labels 2026-07-25 18:44:25 -04:00
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: didericis/bot-bottle#423