diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a59c32432..237b6d3080a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1142,6 +1142,13 @@ jobs: # Serial: windows_resolver_tests mutate process-global env # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-git-identity) + # Exercises capture_raw_bounded Windows tree-teardown: a PowerShell root + # backgrounds a descendant (records its PID), exits or times out, and the + # Job Object kill-on-close must reap the descendant before this returns. + # The two tree-ownership regressions are #[ignore]-tagged because they + # require real PowerShell; --ignored executes them here. + run: cargo test -p buzz-git-identity --target $env:TARGET -- --ignored --test-threads=1 # Smoke-test the new host-prereq contract: Git for Windows (which provides # bash) is available on the runner, a shell command round-trips, and bash # does NOT resolve from System32 (so WSL's launcher is never picked up). diff --git a/Cargo.lock b/Cargo.lock index 9544a63b899..c5db495c51d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,12 +832,15 @@ dependencies = [ "anyhow", "base64 0.22.1", "buzz-core", + "buzz-git-identity", "buzz-persona", "buzz-sdk", "chrono", "clap", "evalexpr", "futures-util", + "git-credential-nostr", + "git-sign-nostr", "hex", "httparse", "nix 0.31.3", @@ -847,6 +850,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", @@ -1104,6 +1108,7 @@ dependencies = [ "base64 0.22.1", "buzz-cli", "buzz-core", + "buzz-git-identity", "git-credential-nostr", "git-sign-nostr", "ignore", @@ -1126,6 +1131,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-git-identity" +version = "0.1.0" +dependencies = [ + "libc", + "nostr 0.44.7", + "tempfile", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "buzz-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d6ee839f1b0..89ba20b4ebc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/buzz-persona", "crates/git-credential-nostr", "crates/git-sign-nostr", + "crates/buzz-git-identity", "crates/buzz-pair-relay", "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806f..91382d63e5f 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -53,6 +53,10 @@ url = { workspace = true } sha2 = { workspace = true } base64 = "0.22" hex = { workspace = true } +buzz-git-identity = { path = "../buzz-git-identity" } +git-credential-nostr = { path = "../git-credential-nostr" } +git-sign-nostr = { path = "../git-sign-nostr" } +tempfile = "3" # Logging tracing = { workspace = true } diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 0d87bca028c..3fd6a3afd99 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -214,6 +214,12 @@ pub struct AcpClient { standard_usage: StandardUsageTracker, /// Known adapter identity for prompt-response usage mapping. standard_adapter: Option, + /// Session-scoped tempdir holding the agent's git identity keyfile and the + /// `git` enforcement-wrapper symlink prepended to the child's PATH. Present + /// only when `NOSTR_PRIVATE_KEY` was set at spawn. Deleted explicitly by + /// [`AcpClient::shutdown`] (the guaranteed-cleanup path); `Drop` is the + /// best-effort fallback for callers that never call `shutdown`. + _git_identity_dir: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -413,6 +419,276 @@ fn build_client_capabilities() -> serde_json::Value { }) } +/// Install deterministic agent git identity onto the about-to-be-spawned agent +/// runtime child (L1b + the L2/L3 wrapper's PATH placement). +/// +/// Builds a session-scoped 0700 tempdir that holds: +/// - the agent's 0600 nostr keyfile (the signer/credential helper read it), +/// - the 0600 identity manifest the wrapper reads as its authority, and +/// - `git`, `git-sign-nostr`, `git-credential-nostr` symlinks back to this +/// binary's own exe, whose multicall dispatch (see [`crate::run`]) makes each +/// name resolve to the matching personality. +/// +/// It then prepends that dir to the child's `PATH` (so the wrapper `git` shadows +/// the real one and the nostr helpers are reachable) and applies the identity + +/// signing `GIT_CONFIG_*` env, composed over any config the caller already set +/// (the desktop's per-URL credential helper). The returned [`TempDir`] owns the +/// keyfile's lifetime and must be held for the life of the child. +/// +/// The agent secret is sourced from the canonical configured key with explicit +/// precedence: `BUZZ_PRIVATE_KEY` (the documented required secret) then the +/// `NOSTR_PRIVATE_KEY` the desktop stages. Eligibility does NOT depend on +/// independent `git-credential-nostr` discovery — this multicall binary IS that +/// helper — so a standard headless `BUZZ_PRIVATE_KEY=… buzz-acp …` launch gets +/// deterministic identity, closing the ambient-Will leak. `NOSTR_PRIVATE_KEY` +/// is also staged on the child (from the canonical key) so dev-mcp's shim, which +/// reads that var, installs the same identity for its own subtree. +/// +/// # Errors +/// +/// `Err` when a key is present but identity cannot be installed (tempdir/chmod/ +/// symlink/keyfile/manifest/PATH failure) — the caller MUST fail the managed +/// session closed rather than spawn an agent that commits as ambient Will. +/// `Ok(None)` only when no key is configured at all (test spawns, genuinely +/// unconfigured sessions): those legitimately spawn without injected identity, +/// and the wrapper (finding no manifest) passes through. +/// +/// Only wired on Unix. Windows agent hosting is not a supported harness surface; +/// the symlink/exec model the wrapper relies on does not exist there. +#[cfg(unix)] +fn install_git_identity( + cmd: &mut tokio::process::Command, +) -> std::io::Result> { + use std::io::{Error, ErrorKind}; + use std::os::unix::fs::symlink; + + // Operator's identity mode, read once here at spawn — never by the wrapper + // per-invocation, which would let any agent `export BUZZ_GIT_IDENTITY=user` + // mid-session and hollow out enforcement. A persona-staged value (on `cmd`) + // outranks the harness process env, so a per-agent `user` setting wins; + // absent both, the default is `agent`. An unrecognized value fails the + // spawn loudly rather than silently picking a mode. + let mode = buzz_git_identity::GitIdentityMode::from_value( + child_env(cmd, buzz_git_identity::GitIdentityMode::ENV_VAR) + .or_else(|| std::env::var_os(buzz_git_identity::GitIdentityMode::ENV_VAR)) + .as_deref(), + ) + .map_err(|e| Error::new(ErrorKind::InvalidInput, e))?; + + // `user` mode: install NO attribution machinery — no wrapper on PATH, no + // manifest, no keyfile, no injected identity/signing config. This is the + // existing, review-hardened unconfigured-session path: the child's git is + // vanilla git resolving the operator's own repo/global identity and + // signing. But it must still stage the canonical key below so the shim's + // credential-helper-only branch can authenticate to relay git — auth ≠ + // attribution holds on every launch path, not just desktop, and an operator + // who set `user` on a configured session has not unconfigured their auth. + + // Canonical agent key. `BUZZ_PRIVATE_KEY` (the documented secret) always + // outranks `NOSTR_PRIVATE_KEY`, at BOTH layers, so a stale or conflicting + // child-staged `NOSTR_PRIVATE_KEY` can never split identity away from the + // canonical `BUZZ_PRIVATE_KEY`. Within a var name, an explicitly cmd-staged + // value (a persona) wins over the ambient process env. Absent entirely → + // unconfigured session, spawn without identity (Ok(None)) in either mode. + let Some(raw_key) = child_env(cmd, "BUZZ_PRIVATE_KEY") + .or_else(|| std::env::var_os("BUZZ_PRIVATE_KEY")) + .or_else(|| child_env(cmd, "NOSTR_PRIVATE_KEY")) + .or_else(|| std::env::var_os("NOSTR_PRIVATE_KEY")) + else { + return Ok(None); + }; + let raw_key = raw_key + .into_string() + .map_err(|_| Error::new(ErrorKind::InvalidInput, "agent key is not valid UTF-8"))?; + + // Stage the canonical key as the child's `NOSTR_PRIVATE_KEY` unconditionally + // — overwriting any pre-staged value — so dev-mcp's shim (which reads that + // var) installs the SAME identity for its own subtree. Staging only when + // absent would let a conflicting child `NOSTR_PRIVATE_KEY` drive the shim to + // a different identity than the one enforced here. Staged in BOTH modes: + // `agent` needs it for the shim's full identity install, `user` needs it for + // the shim's credential-helper-only branch (relay git auth). + cmd.env("NOSTR_PRIVATE_KEY", &raw_key); + + // Whether a relay git credential helper is already configured for this + // child. The desktop stages a per-URL `credential./git.helper` into + // the harness env the child inherits; a headless native `buzz-acp` launch + // stages nothing. Installing the nostr helper at the harness layer only + // when none is configured gives relay git auth on EVERY launch path + // (auth ≠ attribution) while leaving the desktop path byte-for-byte + // unchanged and never double-applying a helper. + let install_credential = !credential_helper_configured(cmd); + + // `user` mode installs NO attribution machinery — no wrapper, manifest, + // keyfile, or authorship/signing config; vanilla git resolves the operator's + // own identity. It must still make relay git auth work headlessly: when no + // helper is configured (non-desktop launch), install ONLY the nostr + // credential helper — a `git-credential-nostr` symlink on PATH plus the + // credential config — which reads the staged `NOSTR_PRIVATE_KEY` from the + // child env. When the desktop already staged its helper, inherit it + // untouched (`Ok(None)`). + if mode == buzz_git_identity::GitIdentityMode::User { + if !install_credential { + return Ok(None); + } + let dir = new_identity_dir()?; + symlink( + &std::env::current_exe()?, + dir.path().join("git-credential-nostr"), + )?; + prepend_child_path(cmd, dir.path())?; + let base = child_git_config_count(cmd); + for (key, value) in buzz_git_identity::to_git_config_env_from_base( + &buzz_git_identity::nostr_credential_entries(), + base, + ) { + cmd.env(key, value); + } + return Ok(Some(dir)); + } + + let self_exe = std::env::current_exe()?; + + let dir = new_identity_dir()?; + + // The wrapper `git` shadows the real one; the two nostr helpers back the + // signing + credential config. All resolve to this binary's multicall. + for name in ["git", "git-sign-nostr", "git-credential-nostr"] { + symlink(&self_exe, dir.path().join(name))?; + } + + // Persist the keyfile and derive the identity. An invalid/empty key here is + // a fatal misconfiguration for a managed session — signing would fail every + // commit — so surface it rather than spawn unattributed. + let id = buzz_git_identity::write_keyfile(dir.path(), &raw_key).ok_or_else(|| { + Error::new( + ErrorKind::InvalidData, + "agent nostr key is empty or invalid", + ) + })?; + + // Write the authoritative identity manifest the enforcement wrapper reads. + // Only the identity/signing entries go into it — the wrapper's manifest + // validator rejects any key outside the eight canonical ones, so the + // credential helper is injected as env config below, never persisted here. + let identity = buzz_git_identity::identity_signing_entries(&id); + buzz_git_identity::write_identity_manifest(dir.path(), &identity)?; + + // Prepend the wrapper dir to the child's PATH so the wrapper `git` shadows + // the real one and the nostr helpers are reachable. + prepend_child_path(cmd, dir.path())?; + + // Identity + signing GIT_CONFIG_*, plus the relay credential helper when one + // is not already configured (so `agent` mode authenticates to relay git + // too, not just attributes commits). Composed over any config already + // present (the desktop's per-URL credential helper) at the next free + // indices, so that helper is preserved. + let mut entries = identity; + if install_credential { + entries.extend(buzz_git_identity::nostr_credential_entries()); + } + let base = child_git_config_count(cmd); + for (key, value) in buzz_git_identity::to_git_config_env_from_base(&entries, base) { + cmd.env(key, value); + } + + Ok(Some(dir)) +} + +/// Create a 0700 session-scoped tempdir to hold the git-identity symlinks, +/// keyfile, and manifest. +#[cfg(unix)] +fn new_identity_dir() -> std::io::Result { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::Builder::new().prefix("buzz-acp-git-").tempdir()?; + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))?; + Ok(dir) +} + +/// Prepend `dir` to the child's `PATH`. Prefer a cmd-staged `PATH` (a persona +/// may set one), fall back to the process env, so we compose rather than +/// clobber in both cases. +#[cfg(unix)] +fn prepend_child_path( + cmd: &mut tokio::process::Command, + dir: &std::path::Path, +) -> std::io::Result<()> { + use std::io::{Error, ErrorKind}; + let base_path = child_env(cmd, "PATH") + .or_else(|| std::env::var_os("PATH")) + .unwrap_or_default(); + let mut entries = vec![dir.to_path_buf()]; + entries.extend(std::env::split_paths(&base_path)); + let joined = + std::env::join_paths(entries).map_err(|e| Error::new(ErrorKind::InvalidInput, e))?; + cmd.env("PATH", joined); + Ok(()) +} + +/// Whether a git credential helper is already configured for `cmd`'s child — +/// on the harness process env (the desktop stages a per-URL +/// `credential./git.helper`) or staged directly on `cmd`. When true the +/// harness must not inject its own relay credential helper: doing so would +/// double-apply it on the desktop path. Scans the `GIT_CONFIG_KEY_*` entries +/// (whose values are git config key names) for any `credential[.*].helper` key. +#[cfg(unix)] +fn credential_helper_configured(cmd: &tokio::process::Command) -> bool { + fn is_helper_key(value: &std::ffi::OsStr) -> bool { + value.to_str().is_some_and(|s| { + let lower = s.to_ascii_lowercase(); + lower == "credential.helper" + || (lower.starts_with("credential.") && lower.ends_with(".helper")) + }) + } + fn is_config_key_var(key: &std::ffi::OsStr) -> bool { + key.to_str() + .is_some_and(|k| k.starts_with("GIT_CONFIG_KEY_")) + } + cmd.as_std() + .get_envs() + .any(|(k, v)| is_config_key_var(k) && v.is_some_and(is_helper_key)) + || std::env::vars_os().any(|(k, v)| is_config_key_var(&k) && is_helper_key(&v)) +} + +#[cfg(not(unix))] +fn install_git_identity( + _cmd: &mut tokio::process::Command, +) -> std::io::Result> { + Ok(None) +} + +/// Read a value previously staged on `cmd` via [`Command::env`], if any. Lets +/// the git-identity installer compose over a `PATH` the spawn path already put +/// on the child rather than clobbering it. +#[cfg(unix)] +fn child_env(cmd: &tokio::process::Command, key: &str) -> Option { + cmd.as_std() + .get_envs() + .find(|(k, _)| *k == std::ffi::OsStr::new(key)) + .and_then(|(_, v)| v.map(|v| v.to_owned())) +} + +/// The `GIT_CONFIG_COUNT` the child will actually see. Agent identity config +/// must be appended STARTING at this index so it never overwrites config the +/// child already carries. +/// +/// This mirrors git's own env resolution: a value staged on `cmd` overrides the +/// process env, and the child otherwise inherits the process env's count. In the +/// Desktop path the child carries `GIT_CONFIG_COUNT=2` and its per-relay +/// credential helper at indices 0–1 while the process env's count is 0, so we +/// must read the child-staged count; in a harness whose own env carries +/// `GIT_CONFIG_*` and stages nothing on the child, the child inherits that +/// count, so we fall back to it. Composing at a lower base would restart the +/// indices and orphan whichever set the child would have seen. +#[cfg(unix)] +fn child_git_config_count(cmd: &tokio::process::Command) -> usize { + child_env(cmd, "GIT_CONFIG_COUNT") + .and_then(|v| v.into_string().ok()) + .or_else(|| std::env::var("GIT_CONFIG_COUNT").ok()) + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + impl AcpClient { /// Kill the agent subprocess and wait for it to exit (no zombies). /// @@ -420,6 +696,19 @@ impl AcpClient { /// Call this when you need guaranteed cleanup — e.g., in `run_models` /// before process exit. pub async fn shutdown(&mut self) { + // Delete the git-identity tempdir (0600 nostr keyfile) explicitly and + // first. `TempDir`'s own `Drop` swallows its `remove_dir_all` error, and + // when the client is dropped right before `std::process::exit` on an + // error/timeout path that removal races the teardown and leaves the + // keyfile on disk ~80% of the time (the leak Gurney found). Closing it + // here — on the guaranteed-cleanup path, before the process-group kill — + // makes removal deterministic and surfaces any failure. + if let Some(dir) = self._git_identity_dir.take() { + let path = dir.path().to_path_buf(); + if let Err(e) = dir.close() { + tracing::warn!("git identity: keyfile cleanup failed for {path:?}: {e}"); + } + } // Kill the entire process group when possible. The child was spawned // with process_group(0), so its PID == its PGID. Killing the group // ensures subprocesses (MCP servers, tool processes) are cleaned up @@ -508,7 +797,14 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if std::env::var_os(key).is_none() { + // `BUZZ_GIT_IDENTITY` is an operator-controlled per-agent exception to + // the parent-wins rule: a persona value must always reach the child so + // `install_git_identity`'s child-over-process lookup can honor a + // per-agent mode even when the harness process env sets a global one. + // Without this, a global `BUZZ_GIT_IDENTITY` silently defeats every + // per-agent override in both directions. + if key == buzz_git_identity::GitIdentityMode::ENV_VAR || std::env::var_os(key).is_none() + { cmd.env(key, value); } } @@ -516,6 +812,23 @@ impl AcpClient { cmd.env("CODEX_CONFIG", merged); } + // ── L1b: deterministic agent git identity for the whole runtime subtree ── + // + // buzz-dev-mcp's shim applies the identity+signing GIT_CONFIG_* only to + // its own shell-tool children. The native shells of claude-code / codex / + // goose never see it, so a bare `git commit` there resolves to whatever + // ambient identity the repo/global config carries (the leak that + // produced attribution gaps). Lift the same identity onto the agent + // runtime child so every native shell inherits it. + // + // Composed over the desktop's per-URL credential-helper GIT_CONFIG_* + // (base-offset preserved). NOSTR_PRIVATE_KEY is staged on the child so + // dev-mcp's shim installs the same identity for its subtree. When a key + // is configured but identity cannot be installed, fail the session + // closed (`?`) rather than spawn an agent that would commit as ambient + // Will; `Ok(None)` (no key at all) spawns unchanged. + let git_identity_dir = install_git_identity(&mut cmd)?; + // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). @@ -563,6 +876,7 @@ impl AcpClient { goose_usage: UsageTracker::default(), standard_usage: StandardUsageTracker::default(), standard_adapter, + _git_identity_dir: git_identity_dir, }) } @@ -3125,6 +3439,53 @@ mod tests { ); } + /// `BUZZ_GIT_IDENTITY` is an operator-controlled per-agent exception to the + /// parent-wins env merge: a persona value must always reach the child so the + /// child-over-process lookup in `install_git_identity` can honor a per-agent + /// mode even when the harness process env sets a conflicting global one. + /// Exercised through the real `AcpClient::spawn` merge loop (the unit tests + /// write directly to `Command` and bypass it). No agent key is set, so + /// `install_git_identity` returns early without installing anything — this + /// isolates the env-merge behavior. + #[cfg(unix)] + #[tokio::test] + async fn spawn_persona_git_identity_overrides_global_process_env() { + const VAR: &str = buzz_git_identity::GitIdentityMode::ENV_VAR; + + /// Restore the process-global `VAR` to its prior state on drop, so this + /// test neither clobbers a caller-supplied value nor leaks its own. + struct EnvGuard(Option); + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.0.take() { + Some(prev) => std::env::set_var(VAR, prev), + None => std::env::remove_var(VAR), + } + } + } + let _guard = EnvGuard(std::env::var_os(VAR)); + + // parent=agent, persona=user → child sees the persona value. + std::env::set_var(VAR, "agent"); + let observed = + spawn_named_and_read_child_env("other-agent", VAR, &[(VAR.into(), "user".into())]) + .await; + assert_eq!( + observed, "user", + "persona `user` must override a global `agent` in the parent env" + ); + + // parent=user, persona=agent → child sees the persona value. + std::env::set_var(VAR, "user"); + let observed = + spawn_named_and_read_child_env("other-agent", VAR, &[(VAR.into(), "agent".into())]) + .await; + assert_eq!( + observed, "agent", + "persona `agent` must override a global `user` in the parent env" + ); + } + #[tokio::test] async fn idle_timeout_fires_on_silent_process() { let mut client = spawn_script("sleep 10").await; @@ -5027,4 +5388,345 @@ mod tests { "error must mention sandbox_workspace_write" ); } + + /// I5: `BUZZ_PRIVATE_KEY` (the documented secret) must win over a + /// conflicting `NOSTR_PRIVATE_KEY`, and the canonical key must be staged as + /// the child's `NOSTR_PRIVATE_KEY` — overwriting the conflicting value — so + /// the harness layer and dev-mcp's shim can never install split identities. + /// Both keys are staged on the command (which outranks the process env for + /// each name), so the test is deterministic regardless of ambient env. + #[cfg(unix)] + #[test] + fn install_git_identity_prefers_buzz_key_and_restages_nostr() { + use nostr::ToBech32; + + let buzz_keys = nostr::Keys::generate(); + let nostr_keys = nostr::Keys::generate(); + let buzz_nsec = buzz_keys.secret_key().to_bech32().unwrap(); + let nostr_nsec = nostr_keys.secret_key().to_bech32().unwrap(); + assert_ne!(buzz_nsec, nostr_nsec, "distinct conflicting keys"); + let buzz_hex = buzz_keys.public_key().to_hex(); + let nostr_hex = nostr_keys.public_key().to_hex(); + + let mut cmd = tokio::process::Command::new("true"); + cmd.env("BUZZ_PRIVATE_KEY", &buzz_nsec); + cmd.env("NOSTR_PRIVATE_KEY", &nostr_nsec); + + let dir = install_git_identity(&mut cmd) + .expect("install must succeed with a valid key") + .expect("a configured key must install identity"); + + // The manifest — the wrapper's authority — must name the BUZZ key. Assert + // on the pubkey (the stable identity), not the host, so the test does not + // depend on a process-global `BUZZ_RELAY_URL` other tests may mutate. + let entries = + buzz_git_identity::read_identity_manifest(dir.path()).expect("manifest written"); + let email = entries + .iter() + .find(|(k, _)| k == "user.email") + .map(|(_, v)| v.clone()) + .expect("manifest has user.email"); + assert!( + email.starts_with(&format!("{buzz_hex}@")), + "BUZZ_PRIVATE_KEY must outrank the conflicting NOSTR_PRIVATE_KEY; got {email:?}" + ); + assert!( + !email.contains(&nostr_hex), + "the conflicting NOSTR key must not have won; got {email:?}" + ); + + // The child's NOSTR_PRIVATE_KEY must have been overwritten to the + // canonical (BUZZ) key, not left at the conflicting staged value. + let staged = child_env(&cmd, "NOSTR_PRIVATE_KEY") + .and_then(|v| v.into_string().ok()) + .expect("NOSTR_PRIVATE_KEY staged on child"); + assert_eq!( + staged, buzz_nsec, + "child NOSTR_PRIVATE_KEY must be the canonical BUZZ key, so dev-mcp's shim \ + installs the same identity" + ); + } + + /// `BUZZ_GIT_IDENTITY=user` (staged per-agent on the command) installs no + /// attribution machinery even with a valid key present, so the child's git + /// resolves the operator's own identity. But a headless launch (no desktop + /// per-URL helper) still needs relay git auth, so the harness installs ONLY + /// the nostr credential helper: a `git-credential-nostr` symlink on PATH and + /// the `credential.helper`/`useHttpPath` config — no authorship or signing. + /// The canonical key is staged as the child's `NOSTR_PRIVATE_KEY` so the + /// helper (and dev-mcp's shim) can load it (auth ≠ attribution on every + /// launch path). The staged mode value outranks the harness process env, + /// proving per-agent control. + #[cfg(unix)] + #[test] + fn install_git_identity_user_mode_installs_credential_helper_but_no_attribution() { + use nostr::ToBech32; + + let nsec = nostr::Keys::generate().secret_key().to_bech32().unwrap(); + let mut cmd = tokio::process::Command::new("true"); + cmd.env("BUZZ_PRIVATE_KEY", &nsec); + cmd.env("BUZZ_GIT_IDENTITY", "user"); + + let dir = install_git_identity(&mut cmd) + .expect("user mode must not error") + .expect("headless user mode must install the credential helper"); + + // The credential helper is reachable and configured, but no attribution. + assert!( + dir.path().join("git-credential-nostr").exists(), + "user mode must symlink the credential helper on PATH" + ); + assert!( + !dir.path().join("git").exists(), + "user mode must not install the enforcement wrapper" + ); + assert!( + buzz_git_identity::read_identity_manifest(dir.path()).is_none(), + "user mode writes no manifest" + ); + assert!( + child_env_has_git_config(&cmd, "credential.helper", "nostr"), + "user mode must configure the nostr credential helper" + ); + assert!( + !child_env_has_git_config_key(&cmd, "user.email"), + "user mode must not inject authorship" + ); + assert!( + !child_env_has_git_config_key(&cmd, "commit.gpgSign"), + "user mode must not inject signing config" + ); + // The canonical key IS staged so the credential helper can load it. + let staged = child_env(&cmd, "NOSTR_PRIVATE_KEY") + .and_then(|v| v.into_string().ok()) + .expect("user mode must stage NOSTR_PRIVATE_KEY for the credential helper"); + assert_eq!(staged, nsec); + } + + /// When the desktop already staged its per-URL relay credential helper on + /// the child, `user` mode inherits it untouched — no double-apply, no + /// harness-installed dir. The desktop path is unchanged. + #[cfg(unix)] + #[test] + fn install_git_identity_user_mode_defers_to_desktop_credential_helper() { + use nostr::ToBech32; + + let nsec = nostr::Keys::generate().secret_key().to_bech32().unwrap(); + let mut cmd = tokio::process::Command::new("true"); + cmd.env("BUZZ_PRIVATE_KEY", &nsec); + cmd.env("BUZZ_GIT_IDENTITY", "user"); + // Mimic the desktop's per-URL helper staged on the child. + cmd.env("GIT_CONFIG_COUNT", "1"); + cmd.env( + "GIT_CONFIG_KEY_0", + "credential.https://relay.example/git.helper", + ); + cmd.env("GIT_CONFIG_VALUE_0", "/opt/buzz/git-credential-nostr"); + + let dir = install_git_identity(&mut cmd).expect("user mode must not error"); + assert!( + dir.is_none(), + "desktop-staged helper means no harness credential install" + ); + // The desktop's config is left exactly as staged. + assert_eq!( + child_env(&cmd, "GIT_CONFIG_COUNT").and_then(|v| v.into_string().ok()), + Some("1".to_string()), + "desktop path must be byte-for-byte unchanged" + ); + } + + /// `agent` (explicit) enforces AND installs the relay credential helper: a + /// valid key installs the identity dir + wrapper, writes the manifest, and + /// injects authorship/signing PLUS `credential.helper=nostr` (so headless + /// agent mode authenticates to relay git, not just attributes commits). + #[cfg(unix)] + #[test] + fn install_git_identity_agent_mode_enforces_and_installs_credential_helper() { + use nostr::ToBech32; + + let nsec = nostr::Keys::generate().secret_key().to_bech32().unwrap(); + let mut cmd = tokio::process::Command::new("true"); + cmd.env("BUZZ_PRIVATE_KEY", &nsec); + cmd.env("BUZZ_GIT_IDENTITY", "agent"); + + let dir = install_git_identity(&mut cmd) + .expect("agent mode must not error") + .expect("agent mode with a key must install identity"); + assert!( + buzz_git_identity::read_identity_manifest(dir.path()).is_some(), + "agent mode must write the enforcement manifest" + ); + // The credential helper is NOT persisted into the manifest (the wrapper + // rejects non-canonical keys) but IS injected as env config. + let manifest = buzz_git_identity::read_identity_manifest(dir.path()).unwrap(); + assert!( + !manifest.iter().any(|(k, _)| k == "credential.helper"), + "credential helper must not enter the wrapper manifest" + ); + assert!( + child_env_has_git_config(&cmd, "credential.helper", "nostr"), + "agent mode must configure relay git auth" + ); + } + + /// P1 (Carl 5046374806): agent mode must LAYER its identity config on top of + /// the Desktop's staged per-relay credential entries, never overwrite them. + /// The Desktop stages `GIT_CONFIG_COUNT=2` and indices 0–1 (its + /// `credential./git.helper` + a companion) directly on the child + /// command, while the harness process env's count is 0. Composing against the + /// process env would restart the agent entries at index 0 and orphan the + /// helper. The fix reads the child's own count, so indices 0–1 survive and + /// the agent config lands at index ≥ 2. + #[cfg(unix)] + #[test] + fn install_git_identity_agent_mode_preserves_desktop_credential_config() { + use nostr::ToBech32; + + let nsec = nostr::Keys::generate().secret_key().to_bech32().unwrap(); + let mut cmd = tokio::process::Command::new("true"); + cmd.env("BUZZ_PRIVATE_KEY", &nsec); + cmd.env("BUZZ_GIT_IDENTITY", "agent"); + // The Desktop's staged per-relay credential config on the child. + cmd.env("GIT_CONFIG_COUNT", "2"); + cmd.env( + "GIT_CONFIG_KEY_0", + "credential.https://relay.example/git.helper", + ); + cmd.env("GIT_CONFIG_VALUE_0", "/opt/buzz/git-credential-nostr"); + cmd.env("GIT_CONFIG_KEY_1", "credential.useHttpPath"); + cmd.env("GIT_CONFIG_VALUE_1", "true"); + + let _dir = install_git_identity(&mut cmd) + .expect("agent mode must not error") + .expect("agent mode with a key must install identity"); + + // The Desktop's indices 0–1 must be byte-for-byte intact. + assert_eq!( + child_env(&cmd, "GIT_CONFIG_KEY_0").and_then(|v| v.into_string().ok()), + Some("credential.https://relay.example/git.helper".to_string()), + "the Desktop's per-relay helper key must be preserved at index 0" + ); + assert_eq!( + child_env(&cmd, "GIT_CONFIG_VALUE_0").and_then(|v| v.into_string().ok()), + Some("/opt/buzz/git-credential-nostr".to_string()), + "the Desktop's per-relay helper value must be preserved at index 0" + ); + assert_eq!( + child_env(&cmd, "GIT_CONFIG_KEY_1").and_then(|v| v.into_string().ok()), + Some("credential.useHttpPath".to_string()), + "the Desktop's companion entry must be preserved at index 1" + ); + + // The agent identity must have been appended above the Desktop entries. + let count = child_git_config_count(&cmd); + assert!( + count > 2, + "agent config must be appended, growing the count past the Desktop's 2; got {count}" + ); + assert!( + child_env_has_git_config_key(&cmd, "user.email"), + "agent authorship must still be injected" + ); + // The Desktop already staged a helper, so the harness must NOT add its + // own nostr credential helper (no double-apply). + assert!( + !child_env_has_git_config(&cmd, "credential.helper", "nostr"), + "a Desktop-staged helper means the harness must not add its own" + ); + } + + /// The credential-helper detection gate: `credential_helper_configured` + /// must recognize a helper staged on `cmd` — both the desktop's per-URL + /// `credential..helper` and a bare `credential.helper` — and report + /// false when only unrelated git config (or none) is present. This gate is + /// what keeps the harness from double-applying a helper on the desktop path. + #[cfg(unix)] + #[test] + fn credential_helper_configured_detects_staged_helpers() { + // The gate also scans process env (a desktop stages the helper there), + // so the false-case assertion below is only meaningful with no ambient + // `GIT_CONFIG_*`. Snapshot every such var, clear them for the test, and + // restore them on drop — a review host may legitimately carry one. + struct GitConfigEnvGuard(Vec<(std::ffi::OsString, std::ffi::OsString)>); + impl Drop for GitConfigEnvGuard { + fn drop(&mut self) { + for (k, v) in self.0.drain(..) { + std::env::set_var(k, v); + } + } + } + let _guard = GitConfigEnvGuard( + std::env::vars_os() + .filter(|(k, _)| k.to_str().is_some_and(|k| k.starts_with("GIT_CONFIG_"))) + .collect(), + ); + for (k, _) in &_guard.0 { + std::env::remove_var(k); + } + + // Bare credential.helper. + let mut bare = tokio::process::Command::new("true"); + bare.env("GIT_CONFIG_KEY_0", "credential.helper"); + assert!(credential_helper_configured(&bare)); + + // Desktop per-URL helper (case-insensitive, URL-scoped). + let mut per_url = tokio::process::Command::new("true"); + per_url.env( + "GIT_CONFIG_KEY_0", + "credential.https://relay.example/git.helper", + ); + assert!(credential_helper_configured(&per_url)); + + // Unrelated git config only → no helper. + let mut unrelated = tokio::process::Command::new("true"); + unrelated.env("GIT_CONFIG_KEY_0", "credential.useHttpPath"); + unrelated.env("GIT_CONFIG_KEY_1", "core.pager"); + assert!(!credential_helper_configured(&unrelated)); + } + + /// Read the flattened `GIT_CONFIG_KEY_n`/`VALUE_n` staged on `cmd` and + /// report whether `key`=`value` is present. + #[cfg(unix)] + fn child_env_has_git_config(cmd: &tokio::process::Command, key: &str, value: &str) -> bool { + git_config_indices(cmd).any(|(k, v)| k == key && v == value) + } + + /// Whether the flattened child git config carries `key` at all. + #[cfg(unix)] + fn child_env_has_git_config_key(cmd: &tokio::process::Command, key: &str) -> bool { + git_config_indices(cmd).any(|(k, _)| k == key) + } + + /// Iterate the `(key, value)` pairs staged on `cmd` as `GIT_CONFIG_KEY_n`/ + /// `GIT_CONFIG_VALUE_n` env vars. + #[cfg(unix)] + fn git_config_indices( + cmd: &tokio::process::Command, + ) -> impl Iterator + '_ { + (0..) + .map(|i| { + ( + child_env(cmd, &format!("GIT_CONFIG_KEY_{i}")), + child_env(cmd, &format!("GIT_CONFIG_VALUE_{i}")), + ) + }) + .take_while(|(k, _)| k.is_some()) + .filter_map(|(k, v)| Some((k?.into_string().ok()?, v?.into_string().ok()?))) + } + + /// An unrecognized value fails the spawn loudly rather than silently + /// picking a mode — the #3140 failure class. + #[cfg(unix)] + #[test] + fn install_git_identity_rejects_invalid_mode() { + let mut cmd = tokio::process::Command::new("true"); + cmd.env("BUZZ_GIT_IDENTITY", "usr"); + let err = install_git_identity(&mut cmd).expect_err("invalid mode must error"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert!( + err.to_string().contains("BUZZ_GIT_IDENTITY"), + "error must name the var; got {err}" + ); + } } diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 4dc4720ed85..9efe1b9758e 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -144,7 +144,7 @@ These are guidelines, not a fixed procedure — apply judgment to the task in fr - After selecting a repository or worktree, read its root `AGENTS.md` and any path-local `AGENTS.md` files that apply before planning or editing. The workspace-level file is team context; it does not replace repository-owned instructions. - Treat repository-owned product, architecture, and vision documents as design constraints, not optional background. Read the relevant documents before making non-trivial plans, and surface any intentional conflict with them. - Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another. -- Before committing, read the repo-local git `user.name` / `user.email`; if email is empty, stop and ask. Include the trailers the repo requires. +- Your commit author identity is machine-managed: every commit is automatically authored and signed as your agent identity (`@`). Never set `user.name`/`user.email`, and never pass `-c user.*`, `--author`, or `--reset-author` — the managed `git` rejects those. Credit the human operator with the `Co-authored-by` and `Signed-off-by` trailers the repo requires (add them to the commit message body); if you cannot determine the operator's email for those trailers, stop and ask. (When the operator sets `BUZZ_GIT_IDENTITY=user`, commits instead carry their own git identity and these trailers are redundant.) ## Autonomy diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 25c6e549052..9936e9d4695 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1895,10 +1895,47 @@ mod idle_pool_sleep_tests { } pub fn run() -> Result<()> { + // Multicall git-helper personalities — when the harness binary is invoked + // under one of these names (via the symlinks it installs on the + // agent-runtime child's PATH; see `AcpClient::install_git_identity`), it + // dispatches to that personality and exits before any harness setup. This + // makes buzz-acp self-contained for deterministic agent git identity: the + // enforcement wrapper AND the nostr signer/credential helper it configures + // are all reachable from a single binary, with no dependency on a separately + // resolvable buzz-dev-mcp. Mirrors buzz-dev-mcp's own shim dispatch so the + // native shells of every runtime hit the same identity-enforcing git. + match git_multicall_personality() { + Some(GitPersonality::Git) => std::process::exit(buzz_git_identity::git_wrapper::run()), + Some(GitPersonality::SignNostr) => std::process::exit(git_sign_nostr::run()), + Some(GitPersonality::CredentialNostr) => std::process::exit(git_credential_nostr::run()), + None => {} + } config::propagate_legacy_env_vars(); tokio_main() } +/// A git-helper multicall personality this binary can assume based on argv[0]. +enum GitPersonality { + Git, + SignNostr, + CredentialNostr, +} + +/// The multicall personality implied by argv[0]'s file stem, or `None` when the +/// binary was launched normally as `buzz-acp`. +fn git_multicall_personality() -> Option { + let stem = std::env::args_os() + .next() + .map(std::path::PathBuf::from) + .and_then(|p| p.file_stem().map(|s| s.to_ascii_lowercase()))?; + match stem.to_str()? { + "git" => Some(GitPersonality::Git), + "git-sign-nostr" => Some(GitPersonality::SignNostr), + "git-credential-nostr" => Some(GitPersonality::CredentialNostr), + _ => None, + } +} + #[tokio::main] async fn tokio_main() -> Result<()> { // Install the ring crypto provider for rustls (required for wss:// connections). @@ -4826,6 +4863,18 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec ! { + client.shutdown().await; + eprintln!("{msg}"); + std::process::exit(code) +} + /// `buzz-acp auth-methods` — spawn an adapter, initialize it, print authMethods. async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> { let mut client = match spawn_auth_client(&args.agent).await { @@ -4839,14 +4888,15 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> { let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await { Ok(Ok(result)) => result, Ok(Err(e)) => { - client.shutdown().await; - eprintln!("error: agent initialize failed: {e}"); - std::process::exit(1); + shutdown_and_exit(client, &format!("error: agent initialize failed: {e}"), 1).await; } Err(_) => { - client.shutdown().await; - eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})"); - std::process::exit(1); + shutdown_and_exit( + client, + &format!("error: agent timed out ({MODELS_TIMEOUT:?})"), + 1, + ) + .await; } }; @@ -4887,14 +4937,15 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await { Ok(Ok(result)) => result, Ok(Err(e)) => { - client.shutdown().await; - eprintln!("error: agent initialize failed: {e}"); - std::process::exit(1); + shutdown_and_exit(client, &format!("error: agent initialize failed: {e}"), 1).await; } Err(_) => { - client.shutdown().await; - eprintln!("error: agent initialize timed out ({MODELS_TIMEOUT:?})"); - std::process::exit(1); + shutdown_and_exit( + client, + &format!("error: agent initialize timed out ({MODELS_TIMEOUT:?})"), + 1, + ) + .await; } }; @@ -4902,12 +4953,15 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { .iter() .any(|method| method.get("id").and_then(|id| id.as_str()) == Some(args.method_id.as_str())); if !supports_method { - client.shutdown().await; - eprintln!( - "error: auth method '{}' is not advertised by this adapter", - args.method_id - ); - std::process::exit(1); + shutdown_and_exit( + client, + &format!( + "error: auth method '{}' is not advertised by this adapter", + args.method_id + ), + 1, + ) + .await; } let result = @@ -4919,14 +4973,15 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { Ok(()) } Ok(Err(e)) => { - client.shutdown().await; - eprintln!("error: authenticate failed: {e}"); - std::process::exit(1); + shutdown_and_exit(client, &format!("error: authenticate failed: {e}"), 1).await; } Err(_) => { - client.shutdown().await; - eprintln!("error: authenticate timed out ({AUTHENTICATE_TIMEOUT:?})"); - std::process::exit(1); + shutdown_and_exit( + client, + &format!("error: authenticate timed out ({AUTHENTICATE_TIMEOUT:?})"), + 1, + ) + .await; } } } @@ -4962,14 +5017,20 @@ async fn run_models(args: ModelsArgs) -> Result<()> { let (init_result, session_resp) = match protocol_result { Ok(Ok(tuple)) => tuple, Ok(Err(e)) => { - client.shutdown().await; - eprintln!("error: agent communication failed: {e}"); - std::process::exit(1); + shutdown_and_exit( + client, + &format!("error: agent communication failed: {e}"), + 1, + ) + .await; } Err(_) => { - client.shutdown().await; - eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})"); - std::process::exit(1); + shutdown_and_exit( + client, + &format!("error: agent timed out ({MODELS_TIMEOUT:?})"), + 1, + ) + .await; } }; diff --git a/crates/buzz-acp/tests/git_identity_enforcement.rs b/crates/buzz-acp/tests/git_identity_enforcement.rs new file mode 100644 index 00000000000..5cb831e0a6a --- /dev/null +++ b/crates/buzz-acp/tests/git_identity_enforcement.rs @@ -0,0 +1,1253 @@ +//! Process-level, mutation-sensitive regression for the deterministic +//! agent-git-identity enforcement wrapper. Unlike the unit tests in +//! `buzz-git-identity`, this exercises the REAL multicall binary: `buzz-acp` +//! symlinked as `git`, invoked exactly as an agent's shell would invoke it, +//! with a `.git-identity` manifest beside the symlink (the harness-owned +//! authority) and the real `git` reachable later on PATH. +//! +//! Each test targets one enforcement layer and is designed to go RED if that +//! layer is deleted: +//! * `enforce` — flag-based identity/signing override is rejected. +//! * `verify_push` — a human-authored outgoing commit cannot be pushed. +//! * `apply_authority_env`— the agent identity is re-applied over caller/repo +//! config (the env-var override vector), so commits land agent-authored +//! even when repo-local config names a human. +//! +//! The whole suite is unix-only: enforcement installs the wrapper as a PATH +//! symlink and every test wires a real `git-sign-nostr` signer via +//! [`signed_shim_env`], both of which need unix symlinks. buzz-acp's tests do +//! not run on Windows CI; this gate keeps `cargo check --all-targets` there +//! from compiling helpers it can never exercise. +#![cfg(unix)] + +use nostr::ToBech32; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const ALIAS_HOP_LIMIT: usize = 10; + +/// A named manifest mutation: a label and a fn that rewrites the manifest body. +/// Aliased to keep the tampered-manifest table under `clippy::type_complexity`. +type ManifestMutation = (&'static str, fn(&str) -> String); + +/// Directory of the first real `git` on PATH; the wrapper is installed ahead +/// of it so `find_real_git` skips our shim symlink and reaches this one. +fn real_git_dir() -> PathBuf { + for dir in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) { + let cand = dir.join("git"); + if cand.is_file() { + return dir; + } + } + panic!("no real git on PATH"); +} + +/// A git repo with one human-authored commit and human-named local config. +fn human_repo() -> tempfile::TempDir { + let d = tempfile::tempdir().unwrap(); + let p = d.path(); + let g = |args: &[&str]| { + let ok = Command::new("git") + .args(args) + .current_dir(p) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?} failed"); + }; + g(&["init", "-q", "-b", "main"]); + g(&["config", "user.name", "Human Dev"]); + g(&["config", "user.email", "human@example.com"]); + g(&["config", "commit.gpgSign", "false"]); + std::fs::write(p.join("f"), "one").unwrap(); + g(&["add", "f"]); + g(&["commit", "-qm", "human commit"]); + d +} + +/// A fresh repo with a staged file but no commit object. +fn unborn_repo() -> tempfile::TempDir { + let d = tempfile::tempdir().unwrap(); + let p = d.path(); + let g = |args: &[&str]| { + let ok = Command::new("git") + .args(args) + .current_dir(p) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?} failed"); + }; + g(&["init", "-q", "-b", "main"]); + g(&["config", "user.name", "Human Dev"]); + g(&["config", "user.email", "human@example.com"]); + g(&["config", "commit.gpgSign", "false"]); + std::fs::write(p.join("f"), "staged").unwrap(); + g(&["add", "f"]); + d +} + +/// Number of commit objects in `repo`, including unreachable objects. +fn commit_object_count(repo: &Path) -> usize { + let out = Command::new("git") + .args([ + "-C", + repo.to_str().unwrap(), + "cat-file", + "--batch-all-objects", + "--batch-check", + ]) + .output() + .unwrap(); + assert!(out.status.success(), "enumerating git objects failed"); + String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|line| line.split_whitespace().nth(1) == Some("commit")) + .count() +} + +/// Invoke the wrapper (`git` on the shim PATH) with `args`, in `cwd`. +/// +/// `NOSTR_PRIVATE_KEY`/`BUZZ_PRIVATE_KEY` are scrubbed so `git-sign-nostr` signs +/// from the manifest's `nostr.keyfile` — the real agent-runtime child has the +/// private key env removed, and leaving the runner's ambient key set would make +/// the signer load the wrong identity (a non-hermetic test). `BUZZ_AUTH_TAG` is +/// scrubbed so the signer skips NIP-OA owner attestation (no relay to verify +/// against offline); signing itself needs no network. +fn wrapper(path: &str, cwd: &Path, args: &[&str]) -> std::process::Output { + Command::new("git") + .args(args) + .current_dir(cwd) + .env("PATH", path) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env_remove("NOSTR_PRIVATE_KEY") + .env_remove("BUZZ_PRIVATE_KEY") + .env_remove("BUZZ_AUTH_TAG") + .output() + .expect("run wrapper git") +} + +/// The current `HEAD` commit SHA of `repo`, via real git (empty if unborn). +fn head_sha(repo: &Path) -> String { + let out = Command::new("git") + .args(["-C", repo.to_str().unwrap(), "rev-parse", "HEAD"]) + .output() + .unwrap(); + if !out.status.success() { + return String::new(); + } + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +#[test] +fn wrapper_rejects_flag_based_identity_override() { + let (_shim, path, _email, _keydir) = signed_shim_env(); + let repo = human_repo(); + std::fs::write(repo.path().join("f"), "two").unwrap(); + wrapper(&path, repo.path(), &["add", "f"]); + + let out = wrapper( + &path, + repo.path(), + &["-c", "user.email=evil@example.com", "commit", "-m", "x"], + ); + assert!( + !out.status.success(), + "override commit should be rejected; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("machine-managed"), + "expected the loud enforce message; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); +} + +#[test] +fn wrapper_refuses_to_push_human_authored_commit() { + let (_shim, path, _email, _keydir) = signed_shim_env(); + let repo = human_repo(); + // A reachable bare remote so the dry-run plan resolves and HEAD (human + // authored) is examined as an offender. + let remote = tempfile::tempdir().unwrap(); + assert!(Command::new("git") + .args(["init", "-q", "--bare", remote.path().to_str().unwrap()]) + .status() + .unwrap() + .success()); + wrapper( + &path, + repo.path(), + &["remote", "add", "origin", remote.path().to_str().unwrap()], + ); + + let out = wrapper(&path, repo.path(), &["push", "origin", "main"]); + assert!( + !out.status.success(), + "pushing a human-authored commit must be refused; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("not authored by your agent identity"), + "expected the push-gate rejection; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + // The bare remote must have received nothing. + let refs = Command::new("git") + .args(["-C", remote.path().to_str().unwrap(), "for-each-ref"]) + .output() + .unwrap(); + assert!( + refs.stdout.is_empty(), + "no ref should have reached the remote: {}", + String::from_utf8_lossy(&refs.stdout), + ); +} + +/// R4 real-wrapper regression for the allowlist alias guard. Two Thufir p3 +/// bypass probes must be refused through the actual `buzz-acp`-as-`git` +/// multicall, and neither may create a commit: +/// +/// (a) a *quoted* config alias — git's quote-aware parser dequotes `'-c'` +/// `'user.email=…'` into real `-c` config that the whitespace-naive +/// round-3 scan missed; +/// (b) a *shell* (`!`) commit alias — git runs it with real git ahead of the +/// wrapper on PATH, so its inner `-c` re-authors the commit. +/// +/// A plain-subcommand alias must still resolve and commit as the agent +/// identity, proving the allowlist did not over-reject Gurney's working shapes. +#[test] +fn wrapper_rejects_quoted_and_shell_aliases_and_allows_plain_alias() { + let (_shim, path, email, _keydir) = signed_shim_env(); + let repo = human_repo(); + + // (a) quoted config alias — the parser-parity bypass. + wrapper( + &path, + repo.path(), + &[ + "config", + "alias.quoted", + "'-c' 'user.name=QuotedHuman' '-c' 'user.email=quoted@human.test' '-c' 'commit.gpgSign=false' commit", + ], + ); + // (b) shell commit alias — git prepends real git to PATH for `!` bodies. + wrapper( + &path, + repo.path(), + &[ + "config", + "alias.sc", + "!f(){ git -c user.name=ShellHuman -c user.email=shell@human.test -c commit.gpgSign=false commit \"$@\"; }; f", + ], + ); + // A plain-subcommand alias that must keep working. + wrapper(&path, repo.path(), &["config", "alias.ci", "commit"]); + + let head_before = head_sha(repo.path()); + std::fs::write(repo.path().join("f"), "two").unwrap(); + wrapper(&path, repo.path(), &["add", "f"]); + + // (a) refused, no commit created. + let out = wrapper(&path, repo.path(), &["quoted", "-m", "via quoted alias"]); + assert!( + !out.status.success(), + "quoted config alias must be refused; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert_eq!( + head_sha(repo.path()), + head_before, + "the refused quoted alias must not create a commit" + ); + + // (b) refused, no commit created. + let out = wrapper(&path, repo.path(), &["sc", "-m", "via shell alias"]); + assert!( + !out.status.success(), + "shell commit alias must be refused; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("shell (`!`) git alias"), + "expected the shell-alias rejection; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert_eq!( + head_sha(repo.path()), + head_before, + "the refused shell alias must not create a commit" + ); + + // The plain alias must still resolve and commit as the agent identity. + let out = wrapper(&path, repo.path(), &["ci", "-m", "via plain alias"]); + assert!( + out.status.success(), + "plain-subcommand alias must still commit; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + let author = Command::new("git") + .args([ + "-C", + repo.path().to_str().unwrap(), + "show", + "-s", + "--format=%ae", + "HEAD", + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&author.stdout).trim(), + email, + "plain-alias commit must be authored as the agent identity" + ); +} + +/// R5 real-wrapper regression for the alias-unification fix (Thufir's rd-4 +/// IMPORTANT). A bare-word alias whose body carries identity/signing *flags* +/// passes the allowlist (every token is a plain bare word), but after the alias +/// is expanded the wrapper holds the expansion to the SAME `enforce`/author +/// policy as a directly-typed command — so the alias can do no more than its +/// expansion could. Three shapes, each of which is refused when typed directly, +/// must therefore be refused through the alias too, with `HEAD` unchanged: +/// +/// (a) Thufir's exact probe — `--author` (split form) plus `--no-gpg-sign`; +/// (b) `--no-gpg-sign` alone, pinning that the fix is not one hard-coded string; +/// (c) an alias *chain* that resolves to `commit --no-gpg-sign` through two +/// hops, pinning that unification applies to the final accumulated command. +#[test] +fn wrapper_rejects_bare_word_alias_carried_identity_and_signing_flags() { + let (_shim, path, _email, _keydir) = signed_shim_env(); + let repo = human_repo(); + + // (a) Thufir's exact bypass probe — bare-word `--author`/`--no-gpg-sign`. + wrapper( + &path, + repo.path(), + &[ + "config", + "alias.human", + "commit --author Human --no-gpg-sign", + ], + ); + // (b) `--no-gpg-sign` alone. + wrapper( + &path, + repo.path(), + &["config", "alias.unsign", "commit --no-gpg-sign"], + ); + // (c) an alias chain: `chain` → `co --no-gpg-sign` → `commit --no-gpg-sign`. + wrapper(&path, repo.path(), &["config", "alias.co", "commit"]); + wrapper( + &path, + repo.path(), + &["config", "alias.chain", "co --no-gpg-sign"], + ); + + let head_before = head_sha(repo.path()); + std::fs::write(repo.path().join("f"), "two").unwrap(); + wrapper(&path, repo.path(), &["add", "f"]); + + for (alias, label) in [ + ("human", "author+no-gpg-sign alias"), + ("unsign", "no-gpg-sign-only alias"), + ("chain", "chained no-gpg-sign alias"), + ] { + let out = wrapper(&path, repo.path(), &[alias, "-m", "leak"]); + assert!( + !out.status.success(), + "{label} must be refused; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("machine-managed"), + "{label} must give the identity/signing rejection; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert_eq!( + head_sha(repo.path()), + head_before, + "{label} must not create a commit" + ); + } +} + +#[test] +fn wrapper_refuses_alias_chain_beyond_limit_and_allows_exact_limit() { + let (_shim, path, email, _keydir) = signed_shim_env(); + let repo = unborn_repo(); + + // Exactly ALIAS_HOP_LIMIT substitutions end at real `commit`, so the wrapper + // must preserve the boundary's useful side: it resolves and commits under + // the managed agent identity. + for index in 0..ALIAS_HOP_LIMIT { + let name = format!("at{index}"); + let next = if index + 1 == ALIAS_HOP_LIMIT { + "commit".to_string() + } else { + format!("at{}", index + 1) + }; + wrapper( + &path, + repo.path(), + &["config", &format!("alias.{name}"), &next], + ); + } + let out = wrapper(&path, repo.path(), &["at0", "-m", "at the alias limit"]); + assert!( + out.status.success(), + "chain at the limit must reach the real command; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + let author = Command::new("git") + .args([ + "-C", + repo.path().to_str().unwrap(), + "show", + "-s", + "--format=%ae", + "HEAD", + ]) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&author.stdout).trim(), email); + + // Thufir's limit+1 counterexample: the wrapper must not hand a partial + // expansion to git. A human-author `commit` beyond the bound is refused + // before git runs, leaving the fresh repo unborn with no commit objects. + let beyond = unborn_repo(); + for index in 0..=ALIAS_HOP_LIMIT { + let name = format!("a{index}"); + let next = if index == ALIAS_HOP_LIMIT { + "commit --author Human".to_string() + } else { + format!("a{}", index + 1) + }; + wrapper( + &path, + beyond.path(), + &["config", &format!("alias.{name}"), &next], + ); + } + let out = wrapper(&path, beyond.path(), &["a0", "-m", "leak"]); + assert!( + !out.status.success(), + "chain past the limit must be refused; stdout={} stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr) + .contains(&format!("after {ALIAS_HOP_LIMIT} expansions")), + "expected the alias-limit refusal; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + let beyond_head = head_sha(beyond.path()); + assert!( + beyond_head.is_empty(), + "HEAD must remain unborn; HEAD={beyond_head:?}; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert_eq!( + commit_object_count(beyond.path()), + 0, + "the refused chain must not create an unreachable commit object" + ); +} + +#[test] +fn wrapper_reapplies_agent_identity_over_repo_config() { + // The env-var / repo-config override vector: repo-local config names a + // human, yet the wrapper re-appends the agent identity at the highest + // GIT_CONFIG_* index, so the resulting commit is agent-authored. Deleting + // `apply_authority_env` makes this commit land as `human@example.com`. + let (_shim, path, email, _keydir) = signed_shim_env(); + let repo = human_repo(); + std::fs::write(repo.path().join("f"), "two").unwrap(); + wrapper(&path, repo.path(), &["add", "f"]); + + let out = wrapper(&path, repo.path(), &["commit", "-m", "agent authored"]); + assert!( + out.status.success(), + "ordinary commit should succeed; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + + let author = Command::new("git") + .args([ + "-C", + repo.path().to_str().unwrap(), + "show", + "-s", + "--format=%ae", + "HEAD", + ]) + .output() + .unwrap(); + assert_eq!( + String::from_utf8_lossy(&author.stdout).trim(), + email, + "commit must be authored as the agent identity, not the repo-local human" + ); +} + +/// Build a shim dir wired for REAL signing: `git` and `git-sign-nostr` both +/// symlink to the buzz-acp multicall, and the `.git-identity` manifest carries +/// the full identity + signing config (`commit.gpgSign=true`, the signer +/// program, `user.signingkey`, and the keyfile) for a freshly generated key. +/// Returns (shim TempDir, PATH string, expected author email, keyfile-holding +/// TempDir). Signing itself needs no network — `BUZZ_AUTH_TAG` is left unset so +/// the signer works offline. +fn signed_shim_env() -> (tempfile::TempDir, String, String, tempfile::TempDir) { + let keys = nostr::Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + + // Keyfile + derived identity live in their own 0700 dir (the manifest's + // `nostr.keyfile` points here). Kept separate from the shim so the shim + // holds only the git symlinks + manifest, as the harness installs them. + let keydir = tempfile::tempdir().unwrap(); + let id = buzz_git_identity::write_keyfile(keydir.path(), &nsec).expect("write keyfile"); + let expected_email = buzz_git_identity::derive_git_email(&id.pubkey_hex); + + let shim = tempfile::tempdir().unwrap(); + for name in ["git", "git-sign-nostr"] { + std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_buzz-acp"), shim.path().join(name)).unwrap(); + } + let entries = buzz_git_identity::identity_signing_entries(&id); + buzz_git_identity::write_identity_manifest(shim.path(), &entries).unwrap(); + + let real = real_git_dir(); + let path = std::env::join_paths([shim.path().to_path_buf(), real]) + .unwrap() + .into_string() + .unwrap(); + (shim, path, expected_email, keydir) +} + +/// A repo whose local config names the AGENT identity (author is correct) and a +/// reachable bare remote, ready for one commit. Returns (work TempDir, repo +/// path, remote path). `commit.gpgSign` is left to the wrapper's injected config +/// so the commit shape is set per-test. +fn agent_repo_with_remote(agent_email: &str) -> (tempfile::TempDir, PathBuf, PathBuf) { + let work = tempfile::tempdir().unwrap(); + let repo = work.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + let remote = work.path().join("remote.git"); + let g = |cwd: &Path, args: &[&str]| { + assert!(Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .status() + .unwrap() + .success()); + }; + g( + work.path(), + &["init", "-q", "--bare", remote.to_str().unwrap()], + ); + g(&repo, &["init", "-q", "-b", "main"]); + g(&repo, &["config", "user.name", "Agent"]); + g(&repo, &["config", "user.email", agent_email]); + g( + &repo, + &["remote", "add", "origin", remote.to_str().unwrap()], + ); + (work, repo, remote) +} + +/// L3b (real signer): a genuinely signed agent commit pushes cleanly. This +/// proves the push-gate signature check accepts a valid NIP-GS signature by the +/// agent key — the happy path that the reject tests below are measured against. +#[test] +fn wrapper_allows_push_of_signed_agent_commit() { + let (_shim, path, email, _keydir) = signed_shim_env(); + let (_work, repo, _remote) = agent_repo_with_remote(&email); + std::fs::write(repo.join("f"), "x").unwrap(); + wrapper(&path, &repo, &["add", "f"]); + // The wrapper injects commit.gpgSign=true + the signer, so this commit is + // signed by the agent key. + let out = wrapper(&path, &repo, &["commit", "-m", "agent signed"]); + assert!( + out.status.success(), + "signed agent commit should succeed; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + let out = wrapper(&path, &repo, &["push", "origin", "main"]); + assert!( + out.status.success(), + "pushing a signed agent commit must be allowed; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// L3b (real signer): an agent-authored but UNSIGNED commit created via +/// `git merge --no-gpg-sign` — Carl's live repro of the P1 gap — must be refused +/// at push. The merge commit is correctly agent-authored, so only the signature +/// check catches it; `enforce` cannot, because `merge` is not in its signing +/// blocklist. +#[test] +fn wrapper_refuses_push_of_unsigned_merge_commit() { + let (_shim, path, email, _keydir) = signed_shim_env(); + let (_work, repo, _remote) = agent_repo_with_remote(&email); + // Base signed commit on main. + std::fs::write(repo.join("base"), "b").unwrap(); + wrapper(&path, &repo, &["add", "base"]); + assert!(wrapper(&path, &repo, &["commit", "-m", "base"]) + .status + .success()); + // A signed commit on a side branch. + wrapper(&path, &repo, &["checkout", "-q", "-b", "side"]); + std::fs::write(repo.join("side"), "s").unwrap(); + wrapper(&path, &repo, &["add", "side"]); + assert!(wrapper(&path, &repo, &["commit", "-m", "side work"]) + .status + .success()); + // Back on main, merge the side branch WITHOUT signing — an agent-authored + // but unsigned merge commit. `--no-ff` forces a merge commit object. + wrapper(&path, &repo, &["checkout", "-q", "main"]); + let m = wrapper( + &path, + &repo, + &[ + "merge", + "--no-ff", + "--no-gpg-sign", + "-m", + "merge side", + "side", + ], + ); + assert!( + m.status.success(), + "the unsigned merge itself should succeed; stderr={}", + String::from_utf8_lossy(&m.stderr), + ); + let out = wrapper(&path, &repo, &["push", "origin", "main"]); + assert!( + !out.status.success(), + "pushing an unsigned merge commit must be refused; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("no valid signature by your agent key"), + "expected the unsigned-commit rejection; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// L3b (real signer): an agent-authored but UNSIGNED commit created via the +/// `commit-tree` plumbing — which bypasses `commit` entirely and so is never +/// touched by `enforce` — must be refused at push. Pins that the push-gate +/// check covers the plumbing path, not just porcelain. +#[test] +fn wrapper_refuses_push_of_unsigned_commit_tree() { + let (_shim, path, email, _keydir) = signed_shim_env(); + let (_work, repo, _remote) = agent_repo_with_remote(&email); + // Seed a signed base so HEAD and the tree exist. + std::fs::write(repo.join("f"), "x").unwrap(); + wrapper(&path, &repo, &["add", "f"]); + assert!(wrapper(&path, &repo, &["commit", "-m", "base"]) + .status + .success()); + // Build an unsigned commit object directly with `commit-tree` (no signing, + // agent identity via env), then move the branch to it. + let tree = wrapper(&path, &repo, &["write-tree"]); + let tree_sha = String::from_utf8_lossy(&tree.stdout).trim().to_string(); + let parent = head_sha(&repo); + let out = Command::new("git") + .args(["commit-tree", &tree_sha, "-p", &parent, "-m", "plumbed"]) + .current_dir(&repo) + .env("PATH", &path) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_NAME", "Agent") + .env("GIT_AUTHOR_EMAIL", &email) + .env("GIT_COMMITTER_NAME", "Agent") + .env("GIT_COMMITTER_EMAIL", &email) + .output() + .expect("run commit-tree"); + assert!( + out.status.success(), + "commit-tree should succeed; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + let new_sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + wrapper(&path, &repo, &["update-ref", "refs/heads/main", &new_sha]); + + let out = wrapper(&path, &repo, &["push", "origin", "main"]); + assert!( + !out.status.success(), + "pushing an unsigned commit-tree commit must be refused; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("no valid signature by your agent key"), + "expected the unsigned-commit rejection; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// Contract regression (Thufir rd-2 IMPORTANT): a `.git-identity` manifest that +/// names the agent but drops, falsifies, or misdirects the signing contract is +/// tampered — not a legitimate unsigned mode — and must fail closed for EVERY +/// command, not silently disable or redirect the push-gate signature check. +/// Six mutations, each a distinct silent-disable/misdirect the contract check +/// rejects at classification time (`run()` refuses before any dispatch), so +/// even a read-only `status` is refused. The duplicate-key and `include.path` +/// cases cover the last-value-wins redirect that a first-value-only validator +/// would miss. +#[test] +fn wrapper_refuses_every_command_when_manifest_signing_contract_is_tampered() { + let variants: [ManifestMutation; 6] = [ + // `commit.gpgSign` removed → the signature gate would never fire. + ("commit.gpgSign removed", |m| { + m.lines() + .filter(|l| !l.starts_with("commit.gpgSign=")) + .collect::>() + .join("\n") + }), + // `commit.gpgSign=false` → the same silent-disable, spelled out. + ("commit.gpgSign=false", |m| { + m.replace("commit.gpgSign=true", "commit.gpgSign=false") + }), + // `user.signingkey` swapped to a key the author email does not encode → + // the probe would trust the wrong key. + ("user.signingkey swapped", |m| { + m.lines() + .map(|l| { + if l.starts_with("user.signingkey=") { + format!("user.signingkey={}", "b".repeat(64)) + } else { + l.to_string() + } + }) + .collect::>() + .join("\n") + }), + // A DUPLICATE later `user.signingkey=B` appended after the canonical + // first value → git config is last-value-wins, so the probe would + // resolve B. classify() rejects any duplicate canonical key. + ("duplicate user.signingkey", |m| { + format!("{m}\nuser.signingkey={}", "b".repeat(64)) + }), + // An appended `include.path` → an unknown key that could pull in another + // key file whose later `user.signingkey` wins. classify() rejects any + // unknown key. + ("appended include.path", |m| { + format!("{m}\ninclude.path=/tmp/evil.inc") + }), + // `gpg.x509.program` differing ONLY in case. On a case-sensitive host + // `GIT-SIGN-NOSTR` resolves past the managed install to an agent- + // controlled fake verifier later on PATH; the probe would then accept + // an unsigned commit. classify() compares fixed values byte for byte. + ("gpg.x509.program cased", |m| { + m.replace( + "gpg.x509.program=git-sign-nostr", + "gpg.x509.program=GIT-SIGN-NOSTR", + ) + }), + ]; + + for (label, mutate) in variants { + let (shim, path, _email, _keydir) = signed_shim_env(); + let manifest_path = shim.path().join(".git-identity"); + let original = std::fs::read_to_string(&manifest_path).unwrap(); + std::fs::write(&manifest_path, mutate(&original)).unwrap(); + + let repo = human_repo(); + let out = wrapper(&path, repo.path(), &["status"]); + assert!( + !out.status.success(), + "[{label}] a tampered manifest must refuse every command; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("complete signing contract"), + "[{label}] expected the tampered-manifest refusal; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + } +} + +/// L3b (real signer): the wrong-key case Thufir called out — a commit correctly +/// authored as the agent (key A) but VALIDLY signed by a DIFFERENT key B must be +/// refused at push. `git-sign-nostr` verifies B's signature as cryptographically +/// good, but the push probe injects the authority's `user.signingkey=A`, so the +/// verified key ≠ the expected key → not `TRUST_FULLY` → `%G?` ≠ `G`. A valid +/// signature by the wrong key is not a valid agent signature. +#[test] +fn wrapper_refuses_push_of_commit_validly_signed_by_wrong_key() { + let (_shim, path, email_a, _keydir_a) = signed_shim_env(); + let (_work, repo, _remote) = agent_repo_with_remote(&email_a); + + // A second, unrelated signing identity (key B) with its own keyfile. + let keys_b = nostr::Keys::generate(); + let nsec_b = keys_b.secret_key().to_bech32().unwrap(); + let keydir_b = tempfile::tempdir().unwrap(); + let id_b = buzz_git_identity::write_keyfile(keydir_b.path(), &nsec_b).expect("write B keyfile"); + + // Create a commit authored as agent A but signed with key B, bypassing the + // wrapper's `enforce` by invoking the real git binary directly with B's + // signing config. `git-sign-nostr` resolves from the shim on PATH. + let real_git = real_git_dir().join("git"); + let out = Command::new(&real_git) + .args([ + "-C", + repo.to_str().unwrap(), + "-c", + "gpg.format=x509", + "-c", + "gpg.x509.program=git-sign-nostr", + "-c", + "commit.gpgSign=true", + "-c", + &format!("user.signingkey={}", id_b.pubkey_hex), + "-c", + &format!("nostr.keyfile={}", id_b.keyfile_path), + "commit", + "--allow-empty", + "-m", + "authored by A, signed by B", + ]) + .current_dir(&repo) + .env("PATH", &path) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .env("GIT_AUTHOR_NAME", "Agent") + .env("GIT_AUTHOR_EMAIL", &email_a) + .env("GIT_COMMITTER_NAME", "Agent") + .env("GIT_COMMITTER_EMAIL", &email_a) + .env_remove("NOSTR_PRIVATE_KEY") + .env_remove("BUZZ_PRIVATE_KEY") + .env_remove("BUZZ_AUTH_TAG") + .output() + .expect("create B-signed commit"); + assert!( + out.status.success(), + "the B-signed commit itself should be created; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + // Sanity: it is agent-authored, so the push gate demands a valid agent + // signature on it (rather than skipping it as someone else's commit). + let author = Command::new(&real_git) + .args([ + "-C", + repo.to_str().unwrap(), + "show", + "-s", + "--format=%ae", + "HEAD", + ]) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&author.stdout).trim(), email_a); + + let out = wrapper(&path, &repo, &["push", "origin", "main"]); + assert!( + !out.status.success(), + "a commit signed by the wrong key must be refused at push; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("no valid signature by your agent key"), + "expected the wrong-key rejection; stderr={}", + String::from_utf8_lossy(&out.stderr), + ); +} + +/// I4: the spawn-path wiring — `AcpClient::spawn` → `install_git_identity` — +/// must actually install the wrapper + manifest onto the agent-runtime child. +/// +/// The tests above wire their own shim + `.git-identity` manifest, so they stay +/// green even if the `install_git_identity(&mut cmd)?` call in `spawn` is +/// deleted. This one drives the REAL `buzz-acp` binary through `buzz-acp models` +/// (whose spawn path is the code under test) with a script agent that runs a +/// bare `git commit` in a human-configured repo and records the resulting +/// author. It passes only when the spawn path installed the wrapper `git` ahead +/// of real git AND wrote a manifest naming the configured key's identity — so +/// removing the `install_git_identity` call makes it go RED (the commit lands as +/// the repo-local human, or fails). +/// +/// `BUZZ_AUTH_TAG` is cleared so `git-sign-nostr` signs offline (no NIP-OA owner +/// attestation to verify against a relay); signing itself needs no network. +#[test] +fn spawn_path_installs_identity_so_agent_commits_land_agent_authored() { + use std::os::unix::fs::PermissionsExt; + + // A configured agent key and its derived author email (the wrapper builds + // `@` from BUZZ_RELAY_URL). + let keys = nostr::Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let pubkey_hex = keys.public_key().to_hex(); + let expected_email = format!("{pubkey_hex}@relay.test"); + + // A human-configured repo with a staged file, ready for one commit. + let work = tempfile::tempdir().unwrap(); + let repo = work.path().join("repo"); + std::fs::create_dir_all(&repo).unwrap(); + let g = |args: &[&str]| { + assert!(Command::new("git") + .args(args) + .current_dir(&repo) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .status() + .unwrap() + .success()); + }; + g(&["init", "-q", "-b", "main"]); + g(&["config", "user.name", "Human Dev"]); + g(&["config", "user.email", "human@example.com"]); + std::fs::write(repo.join("f"), "hi").unwrap(); + g(&["add", "f"]); + + // Script "agent": commit in the repo using whatever `git` its PATH resolves + // (the wrapper, if the spawn path installed it), record the author, exit. + let out_file = work.path().join("author.txt"); + let agent = work.path().join("agent.sh"); + std::fs::write( + &agent, + format!( + "#!/usr/bin/env bash\n\ + cd {repo:?}\n\ + git commit -m 'agent authored' >/dev/null 2>&1\n\ + git show -s --format=%ae HEAD > {out:?} 2>/dev/null\n\ + exit 0\n", + repo = repo, + out = out_file, + ), + ) + .unwrap(); + std::fs::set_permissions(&agent, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // Drive the real binary. `models` spawns the agent (running install_git_identity), + // then fails init (the script exits) — expected; we assert on the side effect. + let output = Command::new(env!("CARGO_BIN_EXE_buzz-acp")) + .args([ + "models", + "--agent-command", + agent.to_str().unwrap(), + "--agent-args", + "", + ]) + .env("BUZZ_PRIVATE_KEY", &nsec) + .env("BUZZ_RELAY_URL", "wss://relay.test") + .env_remove("NOSTR_PRIVATE_KEY") + .env_remove("BUZZ_AUTH_TAG") + .output() + .expect("run buzz-acp models"); + + let author = std::fs::read_to_string(&out_file).unwrap_or_default(); + assert_eq!( + author.trim(), + expected_email, + "spawn path must install the wrapper + manifest so the agent's commit is \ + authored as the configured key's identity; got {author:?}. models stderr: {}", + String::from_utf8_lossy(&output.stderr), + ); +} + +/// Wes (5055999359) P1 — real-wrapper regression: `--receive-pack` (custom +/// receivepack program) is refused through the ACTUAL `buzz-acp`-as-`git` +/// multicall. Also covers `remote.origin.receivepack` config spelling. +/// +/// Bypass shape (what happens WITHOUT the guard): +/// +/// `origin` URL → `decoy` (bare repo seeded with the offending human HEAD). +/// receivepack script → ignores its `` argument; exec's +/// `git-receive-pack ` instead. +/// +/// 1. `resolve_push_sources` runs `git push --dry-run --porcelain`. +/// Git invokes the script as the receive-pack process; negotiation +/// happens against `actual` (empty). The porcelain `To` header shows +/// the `origin` URL (`decoy`). +/// 2. `remote_object_ids` calls `git ls-remote decoy` → returns HEAD's IDs +/// → those IDs are used as the exclusion set → HEAD is treated as +/// already-remote → `partition_outgoing` finds zero offenders. +/// 3. The real push runs; git calls the script again → data flows to +/// `actual` → HEAD populates `actual`. +/// +/// With guard: `reject_receive_pack_override` fires before any dry-run → +/// `actual` stays empty. +/// Without guard: decoy supplies exemption; push lands in `actual`. +/// +/// Mutation-sensitive: removing `reject_receive_pack_override` from +/// `verify_push` makes the bypass succeed; `actual/refs/heads/main` appears +/// and the `show-ref` assertion fires. +#[test] +fn wrapper_refuses_receive_pack_flag_and_leaves_target_empty() { + use std::os::unix::fs::PermissionsExt; + + let (_shim, path, _email, _keydir) = signed_shim_env(); + let repo = human_repo(); + // `decoy` already holds the offending HEAD — ls-remote here supplies the + // exclusion set that exempts HEAD from outgoing verification. + let decoy = tempfile::tempdir().unwrap(); + // `actual` starts empty — this is where data lands under the bypass. + let actual = tempfile::tempdir().unwrap(); + + // Init both bare repos. + assert!(Command::new("git") + .args(["init", "-q", "--bare", decoy.path().to_str().unwrap()]) + .status() + .unwrap() + .success()); + assert!(Command::new("git") + .args(["init", "-q", "--bare", actual.path().to_str().unwrap()]) + .status() + .unwrap() + .success()); + + // Seed decoy with the human HEAD commit. + // ls-remote on decoy returns HEAD's IDs; without the guard those IDs + // exempt HEAD and the push succeeds to actual. + assert!(Command::new("git") + .args([ + "-C", + repo.path().to_str().unwrap(), + "push", + "-q", + decoy.path().to_str().unwrap(), + "HEAD:refs/heads/main", + ]) + .status() + .unwrap() + .success()); + + // Wire origin → decoy. The porcelain To header will show the decoy URL; + // ls-remote on that URL returns HEAD's IDs (the bypass exclusion source). + wrapper( + &path, + repo.path(), + &["remote", "add", "origin", decoy.path().to_str().unwrap()], + ); + + // The receivepack script ignores its argument and routes all + // receive-pack traffic to `actual` instead. Without the guard, git's + // dry-run + real push both talk to actual via this script while ls-remote + // exempts HEAD by reading decoy. + let actual_path = actual.path().to_str().unwrap().to_owned(); + let script = repo.path().join("rp.sh"); + std::fs::write( + &script, + format!("#!/bin/sh\nexec git-receive-pack {actual_path}\n"), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // ── Test 1a: --receive-pack=