feat(acp): add PermissionMode::Auto + contradiction matrix rows (#4938)

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-06 17:00:12 -04:00
co-authored by Will Pfleger
parent 4f72c3c223
commit 6dbbc67f7c
2 changed files with 79 additions and 0 deletions
+47
View File
@@ -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());
}
}
+32
View File
@@ -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<PermissionMode>,
@@ -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),