Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
108 changes: 106 additions & 2 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down