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
193 changes: 115 additions & 78 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ pub struct AcpClient {
/// a `cancelled` outcome before the agent returns from `session/prompt`.
pending_permission_id: Option<serde_json::Value>,
/// Whether we have already sent a response to the pending permission request.
/// Guards against double-response if a timeout fires after the allow_once
/// Guards against double-response if a timeout fires after the rejection
/// response was written but before `pending_permission_id` was cleared.
permission_responded: bool,
/// The JSON-RPC id of the most recently sent `session/prompt` request.
Expand Down Expand Up @@ -1162,7 +1162,8 @@ impl AcpClient {
///
/// While waiting, handles:
/// - `session/update` notifications → logged via tracing
/// - `session/request_permission` requests → auto-approved with `allow_once`
/// - `session/request_permission` requests → rejected unless an owner has
/// already selected a non-interactive permission mode at session setup
/// - Any other messages → debug-logged and ignored; if they carry an `id`
/// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent.
///
Expand Down Expand Up @@ -1870,12 +1871,12 @@ impl AcpClient {
}
}

/// Auto-approve a `session/request_permission` request from the agent.
/// Reject a `session/request_permission` request from the agent.
///
/// Finds the option with `kind == "allow_once"` and responds with its `optionId`.
/// If no `allow_once` option exists, falls back to `reject_once`.
///
/// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`.
/// Buzz has no human permission prompt in this harness, so selecting
/// `allow_once` would turn any admitted prompt into an implicit approval.
/// Find `reject_once` by kind when the adapter offers it; otherwise use the
/// protocol's cancelled outcome, which is also fail-closed.
///
/// The request `id` is stored as `serde_json::Value` to support both numeric
/// and string IDs per JSON-RPC 2.0.
Expand All @@ -1901,40 +1902,7 @@ impl AcpClient {
options.len()
);

// Find allow_once by kind — NEVER hardcode optionId.
let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));

let response = if let Some(opt) = allow_once {
let option_id = opt["optionId"]
.as_str()
.ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?;
tracing::info!(
target: "acp::permission",
"auto-approving permission id={id} with allow_once optionId={option_id:?}"
);
permission_response_selected(&id, option_id)
} else {
// No allow_once — fall back to reject_once.
tracing::warn!(
target: "acp::permission",
"no allow_once option found in permission request id={id}, falling back to reject_once"
);
let reject = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));

if let Some(opt) = reject {
let option_id = opt["optionId"].as_str().unwrap_or("reject");
permission_response_selected(&id, option_id)
} else {
return Err(AcpError::Protocol(
"no suitable permission option found (neither allow_once nor reject_once)"
.into(),
));
}
};
let response = permission_denial_response(&id, options)?;

// Write the response first, then mark as responded.
//
Expand Down Expand Up @@ -2046,6 +2014,42 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
})
}

/// Choose the fail-closed response to a `session/request_permission` request.
///
/// Buzz has no human permission prompt in this harness, so selecting
/// `allow_once` would turn any admitted prompt into an implicit approval.
/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a
/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for
/// adapters that do not offer one. Both answers deny.
///
/// Kept free of the client so the decision is testable without an agent
/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes.
fn permission_denial_response(
id: &serde_json::Value,
options: &[serde_json::Value],
) -> Result<serde_json::Value, AcpError> {
let reject_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));

let Some(opt) = reject_once else {
tracing::warn!(
target: "acp::permission",
"no reject_once option found in permission request id={id}, cancelling"
);
return Ok(permission_response_cancelled(id));
};

let option_id = opt["optionId"]
.as_str()
.ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?;
tracing::info!(
target: "acp::permission",
"rejecting permission id={id} with reject_once optionId={option_id:?}"
);
Ok(permission_response_selected(id, option_id))
}

/// Full `session/new` response — session ID plus the raw JSON result.
///
/// Callers use the extractor helpers to pull model info from `raw`.
Expand Down Expand Up @@ -2300,63 +2304,96 @@ mod tests {
assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal));
}

fn options(json: &str) -> Vec<serde_json::Value> {
serde_json::from_str(json).expect("option list")
}

fn outcome(response: &serde_json::Value) -> Option<&str> {
response["result"]["outcome"]["outcome"].as_str()
}

/// The offered `allow_once` and `allow_always` options must be ignored:
/// there is no human to click them, so choosing either would make every
/// admitted prompt an implicit approval. `optionId`s are deliberately
/// non-obvious to prove they are matched by `kind`, never hardcoded.
#[test]
fn find_allow_once_by_kind_not_by_option_id() {
// optionId values are intentionally non-obvious to prove we don't hardcode them.
let options: Vec<serde_json::Value> = serde_json::from_str(
fn permission_requests_select_reject_once_not_allow_once() {
let options = options(
r#"[
{"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"},
{"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"},
{"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"}
]"#,
)
.unwrap();
);

let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
let response =
permission_denial_response(&serde_json::json!(7), &options).expect("denial response");

assert!(allow_once.is_some(), "should find allow_once option");
let opt = allow_once.unwrap();
// Found by kind, not by hardcoded optionId
assert_eq!(opt["kind"].as_str(), Some("allow_once"));
assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99"));
assert_eq!(outcome(&response), Some("selected"));
assert_eq!(
response["result"]["outcome"]["optionId"].as_str(),
Some("opt-reject-42"),
"must select reject_once even when allow options are offered"
);
}

/// Fail-closed backstop: an adapter that offers no `reject_once` must still
/// be denied, via the protocol's cancelled outcome rather than an error or
/// an approval.
#[test]
fn find_allow_once_returns_none_when_absent() {
let options: Vec<serde_json::Value> = serde_json::from_str(
fn permission_request_without_reject_once_is_cancelled() {
let options = options(
r#"[
{"optionId": "reject-1", "name": "Reject", "kind": "reject_once"},
{"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"}
{"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"},
{"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"}
]"#,
)
.unwrap();
);

let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
let response = permission_denial_response(&serde_json::json!("req-1"), &options)
.expect("cancelled response");

assert!(allow_once.is_none());
assert_eq!(outcome(&response), Some("cancelled"));
assert_eq!(
response["id"].as_str(),
Some("req-1"),
"string ids must round-trip per JSON-RPC 2.0"
);
}

/// An empty option list is the degenerate form of the same backstop.
#[test]
fn find_reject_once_fallback_when_no_allow_once() {
let options: Vec<serde_json::Value> = serde_json::from_str(
r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#,
)
.unwrap();
fn permission_request_with_no_options_is_cancelled() {
let response =
permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response");

let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
assert!(allow_once.is_none());
assert_eq!(outcome(&response), Some("cancelled"));
}

let reject_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
assert!(reject_once.is_some());
assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x"));
/// A `reject_once` option missing its `optionId` is a protocol violation.
/// Erroring propagates to the caller, which tears the turn down — still no
/// approval is ever sent.
#[test]
fn reject_once_without_option_id_is_a_protocol_error() {
let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#);

let err = permission_denial_response(&serde_json::json!(1), &options)
.expect_err("missing optionId must error");

assert!(matches!(err, AcpError::Protocol(_)), "got {err:?}");
}

#[test]
fn find_reject_once_by_kind() {
let options =
options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#);

let response =
permission_denial_response(&serde_json::json!(1), &options).expect("denial response");

assert_eq!(
response["result"]["outcome"]["optionId"].as_str(),
Some("rej-x")
);
}

#[test]
Expand Down
Loading
Loading