diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d9596858460..cd3385b40db 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -159,6 +159,36 @@ impl std::fmt::Display for PermissionMode { } } +/// One-line operator warning for a permission mode that silently denies the +/// agent's own reply path. Returns `None` for modes that don't. +/// +/// `dontAsk` is the default. It overrides the agent's configured default mode, +/// so every tool call not matched by a preauthorization rule is denied without +/// prompting — including the bundled `buzz` CLI the agent is prompted to reply +/// with. The resulting failure is invisible from the outside: the denial +/// happens *inside* the agent, so no `session/request_permission` ever reaches +/// this harness and there is nothing for it to log. An agent stays connected +/// and subscribed, consumes a full turn, and publishes nothing. +/// +/// Stating it once at startup costs one line and saves operators from +/// diagnosing an empty channel as a relay or delivery problem. +/// +/// Kept as a free function over the mode so the text is unit-testable without +/// constructing a full [`Config`]. +pub fn unattended_denial_warning(mode: PermissionMode) -> Option<&'static str> { + matches!(mode, PermissionMode::DontAsk).then_some( + "permission_mode=dontAsk — tool calls not matched by a preauthorization rule are denied \ + inside the agent without prompting, including the bundled `buzz` CLI agents use to \ + publish replies, so an agent may consume a full turn and post nothing. Matching \ + permissions.allow rules do still apply, but Claude Code ignores a project's \ + .claude/settings.json in a workspace that was never trusted interactively — use \ + .claude/settings.local.json to preauthorize commands for a headless agent directory. \ + To leave the agent's own default mode in charge instead, set --permission-mode \ + (BUZZ_ACP_PERMISSION_MODE) to `default`; unattended permission requests that reach the \ + harness are still rejected either way.", + ) +} + /// CLI args for `buzz-acp models` — query available models from an agent. /// /// This is a standalone `Parser` (not a subcommand variant) because the @@ -427,8 +457,31 @@ pub struct CliArgs { /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// - /// Defaults to `dontAsk`, which rejects operations that need interactive - /// approval because Buzz does not expose a human permission prompt. + /// Defaults to `dontAsk`, which denies operations that need interactive + /// approval, without prompting, because Buzz does not expose a human + /// permission prompt. It is applied with `session/set_config_option` + /// *after* the session is created, so it overrides the agent's configured + /// **default mode** — but not its preauthorization: matching + /// `permissions.allow` rules are still evaluated per tool call, so a + /// pre-authorized command runs and only unmatched ones are denied. + /// + /// One caveat bites headless deployments. Claude Code ignores + /// `permissions.allow` from a project's `.claude/settings.json` in a + /// workspace that was never trusted interactively — which is every + /// desktop-managed agent directory — and says so only on its own stderr. + /// `.claude/settings.local.json` is honoured in that case and is the + /// reliable target for a seeded rule. + /// + /// An unmatched call under `dontAsk` includes the bundled `buzz` CLI, + /// which is how agents publish their replies — so an agent can receive a + /// mention, do the work, and then be denied the send. + /// + /// Select `default` to leave the agent's own configuration in charge: the + /// harness sends no `set_config_option` at all in that mode. This is the + /// supported way to run an agent that pre-authorizes its own tools, and it + /// does not weaken the harness guarantee — any `session/request_permission` + /// that reaches buzz-acp is still rejected, because there is no human to + /// ask. #[arg( long, env = "BUZZ_ACP_PERMISSION_MODE", @@ -2276,6 +2329,57 @@ channels = "ALL" assert!(!PermissionMode::Plan.is_default()); } + #[test] + /// Only `dontAsk` denies the agent's reply path, so only `dontAsk` warns. + /// A warning on every mode would be noise operators learn to skip. + fn test_unattended_denial_warning_only_for_dont_ask() { + assert!(unattended_denial_warning(PermissionMode::DontAsk).is_some()); + assert!(unattended_denial_warning(PermissionMode::Default).is_none()); + assert!(unattended_denial_warning(PermissionMode::AcceptEdits).is_none()); + assert!(unattended_denial_warning(PermissionMode::Plan).is_none()); + } + + #[test] + /// The warning is only useful if it names the way out. Pin both the escape + /// hatch and the guarantee it does not weaken, so a future edit that drops + /// either one fails here instead of shipping a dead-end warning. + fn test_unattended_denial_warning_names_the_escape_hatch() { + let warning = unattended_denial_warning(PermissionMode::DontAsk) + .expect("dontAsk must produce a warning"); + assert!( + warning.contains("BUZZ_ACP_PERMISSION_MODE"), + "warning must name the env var operators can set, got: {warning}" + ); + assert!( + warning.contains("`default`"), + "warning must name the mode that defers to the agent's config, got: {warning}" + ); + assert!( + warning.contains("still rejected"), + "warning must state the harness guarantee is unchanged, got: {warning}" + ); + } + + #[test] + /// The warning must not tell operators that agent-side preauthorization is + /// futile — matching allow rules are evaluated per tool call even under + /// `dontAsk`. The trap is the workspace-trust gate, which silently drops + /// `permissions.allow` from a project `settings.json` in a nest that was + /// never opened interactively, so name the file that does work. + fn test_unattended_denial_warning_points_at_settings_local() { + let warning = unattended_denial_warning(PermissionMode::DontAsk) + .expect("dontAsk must produce a warning"); + assert!( + warning.contains("settings.local.json"), + "warning must name the settings file honoured in an untrusted workspace, \ + got: {warning}" + ); + assert!( + warning.contains("do still apply"), + "warning must not imply allow rules are ineffective under dontAsk, got: {warning}" + ); + } + #[test] fn test_permission_mode_display() { assert_eq!(format!("{}", PermissionMode::DontAsk), "dontAsk"); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 65c9dd6203c..c5029905804 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1578,6 +1578,13 @@ async fn tokio_main() -> Result<()> { tracing::info!("buzz-acp starting: {}", config.summary()); + // The dontAsk denial happens inside the agent, so it never surfaces as a + // harness log line. Say it once here rather than leave an empty channel as + // the only symptom. + if let Some(warning) = crate::config::unattended_denial_warning(config.permission_mode) { + tracing::warn!("buzz-acp: {warning}"); + } + let observer = config .relay_observer .then(observer::ObserverHandle::in_process);