From 59198931c785d8fac52d843abd5f45bfa626afd1 Mon Sep 17 00:00:00 2001 From: Marwan Date: Sat, 8 Aug 2026 15:15:06 +0400 Subject: [PATCH 1/2] docs(acp): explain dontAsk's reply-path denial and the `default` escape hatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dontAsk` became the default in #4609 and is applied with `session/set_config_option` after session creation, so it overrides whatever `permissions.defaultMode` / `permissions.allow` the agent's own configuration established. Under that mode the bundled `buzz` CLI is itself an approval-gated tool call, so an agent can receive a mention, do the work, and be denied the send. The failure leaves no trace in the harness: the denial happens inside the agent, so no `session/request_permission` ever reaches buzz-acp and there is nothing for it to log. From outside, the agent is connected, subscribed, billing a full turn, and posting nothing. `--permission-mode default` already avoids this — `apply_permission_mode` is gated on `!is_default()`, so no `set_config_option` is sent and the agent's own configuration stays in charge — but nothing says so, and operators reasonably read "default" as "the agent's per-call prompt" (which `dontAsk` then rejects). No behaviour change. The harness guarantee from #4609 is untouched: any `session/request_permission` that reaches buzz-acp is still answered with `reject_once` or cancellation, in every mode. - Document on `--permission-mode` that the mode is stamped over the agent's own configuration, what that costs under `dontAsk`, and that `default` opts out without weakening the guarantee. - Warn once at startup when `dontAsk` is in effect, naming the consequence and the escape hatch, so an empty channel is not the only symptom. Refs #5290, #5303, #5262, #2884 Signed-off-by: Marwan --- crates/buzz-acp/src/config.rs | 74 ++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/lib.rs | 7 ++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index d9596858460..5ea78eb913d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -159,6 +159,34 @@ 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, and it is applied with `session/set_config_option` +/// after session creation, so it overrides whatever the agent's own +/// configuration established. 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 — because the bundled `buzz` CLI it is prompted +/// to reply with is itself an approval-gated tool call. +/// +/// 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 — approval-gated tool calls are denied inside the agent, \ + including the bundled `buzz` CLI agents use to publish replies, so an agent may \ + consume a full turn and post nothing. This is applied after session creation and \ + overrides the agent's own permissions configuration. To leave that configuration in \ + charge, 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 @@ -428,7 +456,20 @@ pub struct CliArgs { /// 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. + /// approval because Buzz does not expose a human permission prompt. Note + /// that this is applied with `session/set_config_option` *after* the + /// session is created, so it overrides any `permissions.defaultMode` or + /// `permissions.allow` rules the agent's own configuration established + /// (e.g. Claude Code's `settings.json`). Under `dontAsk` that includes the + /// bundled `buzz` CLI, which is how agents publish their replies — 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 +2317,37 @@ 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] 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); From 2be1e0ffac2d47f3c6e7c808d78d67d35da0adff Mon Sep 17 00:00:00 2001 From: Marwan Date: Sun, 9 Aug 2026 09:48:20 +0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(acp):=20correct=20the=20allow-rule=20c?= =?UTF-8?q?laim=20=E2=80=94=20trust=20gate,=20not=20mode=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #5316: `dontAsk` overrides the agent's configured *default mode*, but matching `permissions.allow` rules are still evaluated per tool call, so a pre-authorized command runs and only unmatched ones are denied. The first draft said the mode override defeats all agent-side preauthorization, which would have told operators the wrong thing. The negative `settings.json` result that suggested otherwise is explained by Claude Code's workspace-trust gate: `permissions.allow` from a project `.claude/settings.json` is dropped in a workspace never trusted interactively — which is every desktop-managed agent directory — while the identical rule in `.claude/settings.local.json` applies. Independently reproduced on #5262 with default setting sources and the filename as the only variable. Help text and startup warning now say allow rules do still apply and name `settings.local.json` as the file that survives the trust gate, so scoped preauthorization is presented as the recovery it actually is, alongside `--permission-mode default`. Still no behaviour change, and the #4609 guarantee is untouched: a `session/request_permission` reaching buzz-acp is answered with `reject_once` or cancellation in every mode. Thanks to @LucasMoskun for the correction and @DamienStevens for the controlled test. Refs #5290, #5303, #5262, #2884 Signed-off-by: Marwan --- crates/buzz-acp/src/config.rs | 76 +++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 5ea78eb913d..cd3385b40db 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -162,14 +162,13 @@ 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, and it is applied with `session/set_config_option` -/// after session creation, so it overrides whatever the agent's own -/// configuration established. 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 — because the bundled `buzz` CLI it is prompted -/// to reply with is itself an approval-gated tool call. +/// `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. @@ -178,12 +177,15 @@ impl std::fmt::Display for PermissionMode { /// constructing a full [`Config`]. pub fn unattended_denial_warning(mode: PermissionMode) -> Option<&'static str> { matches!(mode, PermissionMode::DontAsk).then_some( - "permission_mode=dontAsk — approval-gated tool calls are denied inside the agent, \ - including the bundled `buzz` CLI agents use to publish replies, so an agent may \ - consume a full turn and post nothing. This is applied after session creation and \ - overrides the agent's own permissions configuration. To leave that configuration in \ - charge, set --permission-mode (BUZZ_ACP_PERMISSION_MODE) to `default`; unattended \ - permission requests that reach the harness are still rejected either way.", + "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.", ) } @@ -455,14 +457,24 @@ 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. Note - /// that this is applied with `session/set_config_option` *after* the - /// session is created, so it overrides any `permissions.defaultMode` or - /// `permissions.allow` rules the agent's own configuration established - /// (e.g. Claude Code's `settings.json`). Under `dontAsk` that includes the - /// bundled `buzz` CLI, which is how agents publish their replies — an - /// agent can receive a mention, do the work, and then be denied the send. + /// 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 @@ -2348,6 +2360,26 @@ channels = "ALL" ); } + #[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");