Skip to content

feat(acp): enforce deterministic agent git commit identity - #6177

Open
wpfleger96 wants to merge 14 commits into
mainfrom
wpfleger/deterministic-agent-commit-identity
Open

feat(acp): enforce deterministic agent git commit identity#6177
wpfleger96 wants to merge 14 commits into
mainfrom
wpfleger/deterministic-agent-commit-identity

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 17, 2026

Copy link
Copy Markdown
Member

Problem

Agent sessions could commit under the human operator's ambient git identity. Only buzz-dev-mcp's shim applied the nostr author/signing GIT_CONFIG_* env, and only to its own shell-tool children — the native shells of claude-code, codex, and goose never saw it. A bare git commit in those shells resolved to whatever user.name/user.email the repo or global config carried, erasing the AI-attribution signal (e.g. #3140, which landed under a human identity).

Change

Makes the agent commit author identity machine-managed and deterministic across every harness. Trailers still credit the human operator (Co-authored-by + Signed-off-by); that policy is unchanged.

  • buzz-git-identity (new crate) — the single source of truth for the author/email, NIP-GS signing, and keyfile logic, consumed by both the dev-mcp shim and the harness so an agent commits under a byte-identical identity regardless of which surface applied it. The identity email is <64-hex-pubkey>@<relay-host>; the author name is the sanitized Buzz display name (falls back to the npub).
  • Harness-owned identity authority. Alongside the keyfile, the harness/shim write a 0600 identity manifest. The enforcement wrapper locates its own install directory by canonicalizing the git on PATH against its own executable — the same trust channel it uses to find the real git, immune to environment rewriting — reads the expected identity from the manifest, and injects it directly as the highest-precedence config, never trusting git config user.email. A manifest that is missing or fails to parse in an otherwise-managed session fails the invocation closed rather than silently dropping enforcement. Manifest present = enforce; genuinely absent (keyless/unconfigured session) = passthrough.
  • git enforcement wrapper (git_wrapper.rs) — installed on PATH ahead of the real binary.
    • Identity is injected, not merely scrubbed. The wrapper injects the manifest's identity + signing config as -c args at the front of the resolved command, so it outranks caller-supplied -c, GIT_CONFIG_PARAMETERS, include.path, and repo/global config alike. Scrubs GIT_AUTHOR_*/GIT_COMMITTER_*; rejects -c user.*, --config-env=user.*, --author, and --reset-author (scoped to commit/am so git log --author still works).
    • Author preflight. commit -C/-c <sha> and --amend create new commits that reuse another commit's author; injected config cannot override a reused author, so the wrapper inspects the resulting author and rejects any commit-creating mode that would leave a non-agent author. Ordinary commits and amends of the agent's own commits pass.
    • Push gate uses git's own resolved plan. Rather than predicting git's transport grammar from argv, the wrapper runs git push --dry-run --porcelain --no-verify <original args> to obtain the exact resolved update set — covering config-defined and inline -c alias.* git aliases, -C/--git-dir, --all/--mirror/--tags, config remote.*.push, and wildcard refspecs — then refuses any outgoing commit not authored by the agent identity, naming the offending sha and email. A human-authored commit is exempted only when it is a patch-id-identical replay of a commit already upstream (a legitimately cherry-picked/rebased human commit — correct attribution, not new agent work masquerading as someone else); any other non-agent author is refused. The dry-run probe is bounded by a hard timeout; a timeout, an unresolvable update set, or any dry-run failure fails closed. The --no-verify on the internal dry-run keeps it from double-running the repo's own pre-push hooks; the real push keeps its hooks, and enforcement runs unconditionally so --no-verify on the real push cannot bypass it.
    • Aliases are allowlisted, not blocklisted. A git alias is expanded by git in-process, and its config-bearing globals land after the wrapper's injected identity/signing -c options, so an alias could otherwise plant higher-precedence config that re-authors or unsigns the commit — and git's quote-aware alias parser sees tokens differently from a naive whitespace scan ('-c' 'user.email=…' dequotes to real config). Rather than model that grammar, the wrapper admits a non-shell alias only when every token of its resolved body is a trivially-safe bare word: no quote or backslash characters, no -c/--config-env channel, and no =-valued option. Anything else is refused. Shell (!) aliases are refused outright in a managed session — git runs their body with the real git ahead of the wrapper on PATH, so an inner -c outranks the inherited authority and can commit or push under an arbitrary identity, unsigned; there is no safe subset to allow. A bare-word alias can still carry identity/signing flags (commit --author … --no-gpg-sign), so on success the alias is expanded and its resolved command — accumulated body tokens across up to ten alias substitutions plus the caller's trailing argv; if another alias remains at that bound, the wrapper refuses rather than treating a partial expansion as resolved — is held to the identical identity/signing policy as the same command typed directly (enforce() and the commit-author preflight, keyed on the expanded subcommand). An alias can therefore never do more than its expansion could typed directly, and there is no alias-specific flag list to keep in sync. Bare-word aliases whose expansion is clean keep working (alias.ci = commit, alias.st = status, alias.lg = log --oneline, alias.pub = push origin main).
    • Signing is enforced. -c commit.gpgSign=false and --no-gpg-sign are rejected at argv; env-based signing-disable is defeated by the injected highest-precedence config.
  • Harness lift (buzz-acp) — AcpClient::spawn writes the keyfile and manifest, installs the wrapper plus the nostr signer/credential helpers via buzz-acp's own multicall personalities, prepends the wrapper dir to the child PATH, and applies the identity + signing GIT_CONFIG_* composed over the desktop's per-URL credential helper. The key is sourced BUZZ_PRIVATE_KEY (the documented required secret) before NOSTR_PRIVATE_KEY at both the command and process-env layers, and the canonical key is restaged unconditionally as the child's NOSTR_PRIVATE_KEY so the harness and dev-mcp shim can never install split identities. A managed session fails closed when deterministic identity cannot be installed; sessions with no nostr key are skipped entirely, so test spawns and unconfigured sessions are unchanged. Unix-only.
  • Prompt guidance (base_prompt.md, nest_agents.md) — identity is machine-managed; credit the operator via Co-authored-by/Signed-off-by trailers, never user.name/user.email/-c/--author.

Scope and ceiling

This is a deterministic best-effort local control, not an adversarial sandbox. Enforcement is a PATH wrapper sharing the OS user with the agent, so it closes accidental identity leakage — the entire class behind #3140 — but does not stop a deliberate bypass: invoking /usr/bin/git by absolute path, env -i, replacing the wrapper on PATH, deleting the manifest, or committing via libgit2/jj all sidestep it by design. A hard guarantee that holds regardless of what the agent runs would require a receive-side gate on the git host; that remains a possible separate follow-up. This PR intentionally targets the default-path commit/push surface, which is where the missed-attribution signal originates.

Verification

Beyond unit tests (buzz-git-identity 66, buzz-acp 817 unit + integration suites), the wrapper mechanism is exercised end-to-end by process-level tests (git_identity_enforcement.rs) that spawn the real buzz-acp-as-git multicall against a real repo with a manifest present:

  • a flag-based identity override (-c user.email=…, --author=…) is rejected;
  • agent identity is injected over conflicting repo config so the resulting commit is authored <display-name> <hex@relay>;
  • a quote-obfuscated config alias (alias.quoted = '-c' 'user.email=…' commit) and a shell (!) commit alias are each refused before git runs and leave HEAD unchanged, while a plain-subcommand alias still resolves and commits agent-authored; a bare-word alias carrying identity/signing flags (alias.human = commit --author … --no-gpg-sign, --no-gpg-sign alone, and a two-hop chain) is expanded and refused by the same policy as the typed command, HEAD unchanged; a chain of exactly ten aliases reaching commit remains usable, while an eleventh alias is refused before git runs with unborn HEAD and zero commit objects;
  • a push containing a human-authored commit is refused via git's resolved plan, while an agent-authored push is allowed;
  • the full spawn path installs the wrapper + manifest so a plain agent commit in a human-configured repo lands agent-authored.

Each process-level test is mutation-verified: nulling the harness's install_git_identity wiring, or dropping the enforce/verify_push dispatch, turns the corresponding test red — confirming the layer is wired into the process boundary, not merely unit-covered. The key-precedence and bounded-probe paths carry their own mutation-sensitive unit tests. The keyfile-lifecycle test (keyfile_lifecycle.rs) spawns the real binary on its error-exit path and confirms the 0600 keyfile is deleted on every exit.

Notes

  • Squash-merge re-authors the merged commit to the PR-opener's GitHub token regardless of this change; the durable merged-history attribution signal is the Co-authored-by trailer email.
  • NIP-GS signatures render as "unverified" on GitHub's UI (GitHub has no nostr x509 trust root); the signature is still verifiable via git-sign-nostr.

Agent sessions could commit under the human operator's ambient git
identity: only buzz-dev-mcp's shim applied the nostr author/signing
GIT_CONFIG_* env, and only to its own shell-tool children. The native
shells of claude-code, codex, and goose never saw it, so a bare
`git commit` there resolved to whatever the repo/global config carried
— erasing the AI-attribution signal (e.g. block/buzz #3140).

Make the identity machine-managed across every harness:

- New `buzz-git-identity` crate holds the pure author/email/signing/
  keyfile logic as the single source of truth, consumed by both the
  shim and the harness so an agent commits under a byte-identical
  identity regardless of which surface applied it.
- A `git` enforcement wrapper (installed on PATH ahead of the real
  binary) scrubs GIT_AUTHOR_*/GIT_COMMITTER_* from the child env,
  rejects `-c user.*`, `--config-env=user.*`, `--author`, and
  `--reset-author`, and on push refuses any outgoing commit not
  authored by the agent identity, then execs real git.
- The harness lifts the identity + NIP-GS signing config onto the
  agent-runtime child and installs the wrapper plus the nostr
  signer/credential helpers via buzz-acp's own multicall, so native
  shells of all runtimes inherit both. Composed over the desktop's
  per-URL credential helper; skipped when no nostr key is present.
- Prompt guidance (base_prompt.md, nest_agents.md) updated: identity
  is machine-managed; credit the operator via Co-authored-by/
  Signed-off-by trailers, never user.name/email/-c/--author.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 17, 2026 23:03
Duncan and others added 13 commits August 18, 2026 10:25
Round-1 review fixes on the deterministic agent-commit-identity work:

- L3 push gate resolved the effective command through git aliases
  (config-defined and inline -c alias.x=push) so a push disguised as a
  custom alias can no longer skip outgoing-author verification.
- Verification subprocesses now carry repository context (-C, --git-dir,
  --work-tree, --namespace); an outgoing tip that resolves to a real ref
  but whose range cannot be computed fails closed instead of being
  skipped as nothing-to-check.
- AcpClient::shutdown deletes the git-identity keyfile tempdir explicitly
  via TempDir::close before the process-group kill. Relying on Drop right
  before std::process::exit leaked the 0600 nostr keyfile ~80% of runs;
  all client-owning error/timeout exits funnel through shutdown_and_exit,
  which takes the client by value.

Checkpoint commit: the review round continues on this branch with the
identity-authority and push-boundary rework.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The enforcement wrapper trusted the same caller-mutable GIT_CONFIG_*
environment it was meant to constrain, so `env -u GIT_CONFIG_COUNT git
commit` fell back to repo-local human identity and the push gate derived
its expected identity from that same mutable config and failed open.

- Authority: harness/shim write a 0600 identity manifest beside the
  keyfile; the wrapper locates its own install dir by PATH
  canonicalization, re-applies identity+signing GIT_CONFIG_* at the
  highest index before exec, and reads L3's expected author from the
  manifest. Manifest present = enforce; absent = passthrough.
- L1b eligibility sources the key from BUZZ_PRIVATE_KEY then
  NOSTR_PRIVATE_KEY, decoupled from credential-helper discovery, and
  fails the managed session closed when identity cannot be installed.
- Push gate uses git's own resolved plan (push --dry-run --porcelain
  --no-verify) instead of predicting argv, covering aliases, -C,
  --all/--mirror/--tags, config refspecs; unresolvable = fail closed.
- Author preflight rejects commit -C/-c/--amend that would leave a
  non-agent author; rebase/cherry-pick of upstream history pass.
- Signing-disable via -c/--no-gpg-sign rejected at argv.
- Process-level mutation tests spawn the real multicall and go red when
  each enforcement layer is removed.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…c-agent-commit-identity

* origin/main:
  Polish mobile timeline navigation (#5874)
  chore(release): release Buzz Desktop version 0.5.17 (#6234)
  fix(prompt): simplify pickup follow-through (#6186)
  fix(mcp): scope todo usage (#6216)
  fix(desktop): bound remote agent mention authorization (#6224)
  fix: bump h2 for RUSTSEC-2026-0258 (#6222)
  fix(desktop): bind presence retry timers (#6213)
  ci: make file-size policy a first-class gate (#6187)
  fix(desktop): eliminate mounted-view CPU burn — compositor-safe shimmer, observer append fast path, poll-tick disk reads (#6198)
  chore(release): release Buzz Desktop version 0.5.16 (#6191)
  fix(desktop): restore release agent mentions (#6182)
  test(desktop): cover exact workflow batch limit (#6168)
  chore(release): release Buzz Desktop version 0.5.15 (#6173)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…c-agent-commit-identity

* origin/main:
  fix(desktop-chrome): preserve balanced layout when sidebar collapses (#6000)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…c-agent-commit-identity

* origin/main:
  feat(managed-agents): close five Claude Code agent-config gaps (#4557)
  chore(hooks): keep mobile analysis out of pre-commit (#6236)
  fix(shared-ui): delay hover disclosures by default (#5821)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Address the reconciled Thufir/Gurney round-2 findings on the deterministic
agent git-identity gate:

- Injected identity args now outrank caller `-c`/GIT_CONFIG_PARAMETERS/
  include.path/repo config, and a tampered manifest fails closed (C1).
- Shell (`!`) push aliases are treated as opaque and rejected before any
  probe, so an alias cannot transmit before verification (C2).
- Author-preserving commit modes gain a patch-id exemption so legitimate
  rebases of upstream human commits pass while new human-authored commits
  are still refused (I3).
- BUZZ_PRIVATE_KEY outranks NOSTR_PRIVATE_KEY at both layers and the
  canonical key is restaged unconditionally as the child NOSTR_PRIVATE_KEY,
  so the harness and dev-mcp shim can never install split identities (I5).
- The push predictor's subprocess probe is bounded by a hard timeout and
  fails closed on expiry (I6).

Adds a real-binary integration test driving the buzz-acp spawn path so the
install_git_identity wiring is regression-covered, plus mutation-sensitive
unit tests for the key precedence and timeout paths.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…c-agent-commit-identity

* origin/main:
  Refine mobile pairing confirmation (#6018)
  chore(scripts): add buzz-adopt-prod-agents.sh (#6250)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
An ordinary (non-shell) git alias whose body carries global `-c`/`--config-env` config is expanded by git in-process after the wrapper's injected identity/signing `-c` options, so the alias-added config outranks the authority and could silently re-author or unsign a commit. enforce() inspects only the literal argv and never the alias body, so it could not catch this — a repo-local alias recreated the original human-attribution leak through the managed wrapper.

Add a pre-exec verify_alias_safety pass that resolves the effective alias chain and refuses any alias whose expansion introduces global configuration, per the favor-rejection principle rather than modeling git's full alias grammar. Plain-subcommand aliases keep working. Shell (`!`) aliases stay out of scope here: their git invocations re-enter the wrapper on PATH and push-bearing ones are already rejected as opaque.

Also gate the unconditional `nostr::ToBech32` import in git_identity_enforcement.rs behind #[cfg(unix)] (its only use is a unix-only test) so the Windows clippy gate stops failing on unused-import under -D warnings.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…shell aliases

The prior blocklist scanned an alias body for known-bad config tokens, but git's quote-aware alias parser dequotes `'-c' 'user.email=…'` into a live config channel that a naive whitespace scan never sees — a parser-parity bypass. And `!` shell aliases were treated as safe on the commit path on the false premise that their inner git re-enters this wrapper; git prepends its own exec-path to PATH, so the inner git is the real binary and its -c outranks the inherited env authority, committing as an arbitrary human, unsigned.

Invert verify_alias_safety to an allowlist: a non-shell alias is admitted only when every body token is a trivially-safe bare word (no quote/backslash, no -c/--config-env in any spelling, no =-valued option); anything else is refused without modeling git's grammar. Reject all shell aliases outright in a managed session, commit path included. This makes the whole config-injection class end by construction. Gurney's certified shapes (ci=commit, st=status, lg=log --oneline, pub=push origin main) stay allowed.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…c-agent-commit-identity

* origin/main:
  Revert "fix(acp): gate relay-signed workflow messages on their attributed author" (#6311)
  fix(desktop): morph the drawer panel icon instead of sliding it (#6306)
  feat(desktop): refine repository-aware project workspaces (#6003)
  Fix mobile Activity thread navigation (#5850)
  perf(desktop): parallelize relay agent directory rebuild (#6258)
  Refine the mobile emoji picker (#5853)
  fix(desktop): exclude archived agents from nest, order regeneration (#5905)
  Add font size and conversation density preferences (#5644)
  fix(desktop): emit camelCase config-write payload fields (#6062)
  fix(desktop): downscale large avatars for agent-share PNG body (#6260)
  fix(desktop): preserve early relay auth challenges (#3320)
  Polish mobile message actions (#5873)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
… policy

A bare-word alias whose body carried identity/signing flags (`commit --author … --no-gpg-sign`) passed the allowlist because every token is a plain bare word, and enforce()/verify_commit_author() keyed on the literal typed subcommand (the alias name, never the expanded `commit`) — so the flag preflights never fired. A repo-local alias through the managed wrapper could author as a human and disable signing.

verify_alias_safety now returns the alias's fully-resolved expansion (typed globals + recursively-expanded command with accumulated body tokens and the user's trailing argv). run() holds that expansion to the same enforce() and verify_commit_author() preflight as a directly-typed command, keyed on the expanded subcommand. An alias can no longer do more than its expansion could typed directly, so there is no alias-specific flag list to maintain. Shell-alias and unclassifiable-syntax refusals are unchanged.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…c-agent-commit-identity

* origin/main:
  fix(buzz-acp): loosen workspace-scan guardrail to allow named paths (#6261)
  fix(buzz-dev-mcp): expand leading ~ in read_file/str_replace paths (#6271)
  perf(desktop): move five hot renderer paths from JS into Rust (#6024)
  fix(media): accept portrait video resolutions (#6058)
  fix(desktop): hide archived channels from #/Tab autocomplete (#6156)
  Unify mobile channel details (#6113)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Git continues resolving aliases after the wrapper reaches its bounded expansion limit. Refuse when the next command word remains an alias so a partial expansion cannot bypass managed identity and signing policy.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant