From 6dbbc67f7c74e1668d0f09bb9c77bf67cb22f1f7 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 17:00:12 -0400 Subject: [PATCH] feat(acp): add PermissionMode::Auto + contradiction matrix rows (#4938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Auto variant to PermissionMode (wire string 'auto'; #4557 adds the same variant from the claude-config arc — this commit establishes the contradiction logic ahead of that merge so the rebase is mechanical). Auto mode = fully autonomous execution; model-gated (requires supportsAutoMode); the adapter self-approves all tool calls internally and never emits session/request_permission. Mode matrix: - allow + auto → compatible (transmit as-is; both want unattended approval) - ask + auto → startup error (card never fires — ask becomes a dead letter) - reject + auto → startup error (inverted-security worst case: policy says deny while adapter silently auto-approves everything) Tests: 4 new pinned tests (allow+auto ok, ask+auto error, reject+auto error, wire string correct). Total: 724 passing. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-acp/src/acp.rs | 47 +++++++++++++++++++++++++++++++++++ crates/buzz-acp/src/config.rs | 32 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8ccf9a48f..554674bdf 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -6254,4 +6254,51 @@ mod tests { let cfg = ResolvedPermissionConfig::resolve(PermissionPolicy::Allow, None).unwrap(); assert_eq!(cfg.effective_mode.as_wire_str(), "default"); } + + // ── Pinned amendment: PermissionMode::Auto matrix row ──────────────────── + // + // `auto` = "fully autonomous execution" — the adapter self-approves all + // tool calls internally and never emits `session/request_permission`. + // - allow + auto → compatible (transmit as-is; both want unattended approval) + // - ask + auto → startup error (card never fires — ask becomes dead letter) + // - reject + auto → startup error (inverted security: policy says deny, adapter + // auto-approves everything) + + #[test] + fn resolved_permission_config_allow_plus_explicit_auto_is_ok() { + // allow + auto is compatible: both want unattended approval. + let cfg = + ResolvedPermissionConfig::resolve(PermissionPolicy::Allow, Some(PermissionMode::Auto)) + .unwrap(); + assert_eq!(cfg.effective_mode, PermissionMode::Auto); + assert_eq!(cfg.effective_mode.as_wire_str(), "auto"); + assert_eq!(cfg.mode_source, ModeSource::Explicit); + } + + #[test] + fn resolved_permission_config_ask_plus_explicit_auto_is_startup_error() { + // ask + auto: adapter self-approves internally, card never fires. + let result = + ResolvedPermissionConfig::resolve(PermissionPolicy::Ask, Some(PermissionMode::Auto)); + assert!(result.is_err(), "ask + auto must be a startup error"); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("auto"), "error must mention auto, got: {msg}"); + } + + #[test] + fn resolved_permission_config_reject_plus_explicit_auto_is_startup_error() { + // reject + auto: inverted-security worst case — policy says deny but + // adapter auto-approves everything internally. + let result = + ResolvedPermissionConfig::resolve(PermissionPolicy::Reject, Some(PermissionMode::Auto)); + assert!(result.is_err(), "reject + auto must be a startup error"); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("auto"), "error must mention auto, got: {msg}"); + } + + #[test] + fn permission_mode_auto_wire_string_is_correct() { + assert_eq!(PermissionMode::Auto.as_wire_str(), "auto"); + assert!(!PermissionMode::Auto.is_default()); + } } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index e16b22141..34423f458 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -115,6 +115,10 @@ impl std::fmt::Display for RespondTo { /// `configId: "mode"` (e.g. `claude-agent-acp`). /// /// - `default` — agent's built-in behaviour (permission requests per tool call). +/// - `auto` — fully autonomous execution; model-gated (requires `supportsAutoMode`); +/// the adapter degrades gracefully to `default` when the active model does not +/// support it. The adapter self-approves all tool calls internally — no +/// `session/request_permission` ever crosses ACP under this mode. /// - `acceptEdits` — auto-approve file edits, still ask for other tools. /// - `dontAsk` — never prompt; reject anything that would require permission. /// - `plan` — planning-only mode (no tool execution). @@ -123,6 +127,15 @@ pub enum PermissionMode { /// Agent default — permission requests per tool call. #[value(alias = "default")] Default, + /// Fully autonomous execution; model-gated (requires `supportsAutoMode`). + /// + /// The adapter self-approves all tool calls internally and never emits + /// `session/request_permission`, so this mode is incompatible with + /// `ask` (card never fires) and `reject` (policy is a dead letter while + /// the adapter auto-approves — the inverted-security worst case). + /// Compatible with `allow` (both want unattended approval). + #[value(alias = "auto")] + Auto, /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, @@ -140,6 +153,7 @@ impl PermissionMode { pub fn as_wire_str(&self) -> &'static str { match self { Self::Default => "default", + Self::Auto => "auto", Self::AcceptEdits => "acceptEdits", Self::DontAsk => "dontAsk", Self::Plan => "plan", @@ -244,6 +258,10 @@ impl ResolvedPermissionConfig { /// - `ask` + explicit `dontAsk` — harness would want the agent to /// escalate, but `dontAsk` makes the agent self-deny internally. /// - `allow` + explicit `dontAsk` — same contradiction. + /// - `ask` + explicit `auto` — adapter self-approves internally, so the + /// card never fires; the `ask` policy becomes a silent dead letter. + /// - `reject` + explicit `auto` — inverted-security worst case: policy says + /// "deny" but the adapter auto-approves everything internally. pub fn resolve( policy: PermissionPolicy, explicit_mode: Option, @@ -257,6 +275,20 @@ impl ResolvedPermissionConfig { dontAsk makes the agent self-deny internally before Buzz can answer" ))); } + // Fail on ask/reject + auto: `auto` makes the adapter self-approve + // internally so `session/request_permission` never crosses ACP. + // Under `ask` the card never fires; under `reject` the policy is a dead + // letter while the adapter silently grants everything (inverted security). + // `allow` + auto is compatible: both policies want unattended approval. + if matches!(policy, PermissionPolicy::Ask | PermissionPolicy::Reject) + && explicit_mode == Some(PermissionMode::Auto) + { + return Err(ConfigError::ConfigFile(format!( + "permission_policy={policy} conflicts with permission_mode=auto: \ + auto makes the adapter self-approve internally before Buzz can answer \ + (ask: card never fires; reject: policy becomes a dead letter)" + ))); + } let (effective_mode, mode_source) = match explicit_mode { Some(m) => (m, ModeSource::Explicit),