Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0ebb2a7
feat(acp): enforce deterministic agent git commit identity
Aug 17, 2026
14afcc2
fix(acp): close push-gate alias/-C bypasses and keyfile teardown race
Aug 18, 2026
079a82c
fix(acp): make agent git identity authoritative and fail-closed
Aug 18, 2026
fb21fd1
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 18, 2026
f2f6d29
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 18, 2026
38aa021
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 18, 2026
db8e45b
fix(acp): close review findings on agent commit-identity enforcement
Aug 18, 2026
6e80027
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 18, 2026
f8acf92
fix(git-identity): reject config-introducing git aliases in wrapper
Aug 19, 2026
7abb86a
fix(git-identity): invert alias guard to an allowlist and refuse all …
Aug 19, 2026
08715a7
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 19, 2026
d963fdb
fix(git-identity): hold expanded alias commands to the direct-command…
Aug 19, 2026
68e9658
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 19, 2026
ac5cdea
fix(git-identity): fail closed at alias resolution limit
Aug 19, 2026
4cc7339
feat(acp): add BUZZ_GIT_IDENTITY toggle for agent vs user commit iden…
Aug 21, 2026
17d50d7
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 21, 2026
2dfbebc
fix(acp): stage canonical key in user mode for the shim credential he…
Aug 21, 2026
f394b65
fix(acp): honor per-agent BUZZ_GIT_IDENTITY over global process env
Aug 21, 2026
c44f02a
Merge remote-tracking branch 'origin/main' into wpfleger/deterministi…
Aug 21, 2026
c64e62e
test(acp): restore BUZZ_GIT_IDENTITY on drop in persona-precedence test
Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
388 changes: 387 additions & 1 deletion crates/buzz-acp/src/acp.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,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 (`<pubkey>@<relay-host>`). 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

Expand Down
121 changes: 91 additions & 30 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1877,10 +1877,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<GitPersonality> {
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).
Expand Down Expand Up @@ -4776,6 +4813,18 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec<serde_json::Valu
.unwrap_or_default()
}

/// Shut down the client and exit with `code` after printing `msg` to stderr.
///
/// `std::process::exit` runs no destructors, so error/timeout paths must call
/// `shutdown().await` — which reaps the child AND deletes the git-identity
/// keyfile tempdir — before exiting. Taking the client by value guarantees no
/// caller can exit while still holding a live client (and thus a live keyfile).
async fn shutdown_and_exit(mut client: AcpClient, msg: &str, code: i32) -> ! {
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 {
Expand All @@ -4789,14 +4838,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;
}
};

Expand Down Expand Up @@ -4837,27 +4887,31 @@ 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;
}
};

let supports_method = extract_auth_methods(&init_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 =
Expand All @@ -4869,14 +4923,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;
}
}
}
Expand Down Expand Up @@ -4915,14 +4970,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;
}
};

Expand Down
Loading
Loading