mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
## Summary
buzz-agent now authorizes every LLM-issued MCP tool call through
`session/request_permission` before it executes, instead of running
tools unconditionally. The agent **always asks**; the client applies
`BUZZ_ACP_PERMISSION_POLICY` and answers. buzz-agent never reads the
policy — this keeps the policy decision on the client side, matching the
layering of the other ACP harnesses, and avoids duplicating policy logic
that would drift.
### Broker
A crate-local `PermissionBroker`
(`crates/buzz-agent/src/permission.rs`), owned by `App` for the
connection lifetime, owns the full request-correlation lifecycle:
- **Process-wide admission.** A global `Semaphore`
(`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`, default 32, validated `>= 1`) is
acquired *before* any correlation entry is inserted. This is the
security bound: the per-turn tool semaphore is constructed fresh per
turn and `max_sessions` is unbounded by default, so neither bounds
simultaneously-outstanding asks process-wide.
- **Abort-safe cleanup.** A successful admission returns a
`PendingPermission` lease that owns the admission permit and the
correlation id. Its `Drop` synchronously removes the still-pending entry
and releases the slot, covering task abort/panic that bypasses the
normal `run_prompt` tail.
- **At-most-once resolution.** `deliver` claims (removes) the entry
*before* waking the waiter, so each id resolves once and a later lease
`Drop` is a no-op. Unknown/late ids are logged and dropped; only ids the
broker minted (`perm-<n>`) are recognized.
- **Single absolute deadline.** Admission, request enqueue, and response
wait all share one deadline (`BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`,
default 330s, validated `>= 1`), so a saturated call cannot outlive one
timeout window even when a stalled writer blocks the enqueue.
Cancellation races inside every wait — resolution never depends on the
outer abort drain. A writer that dies mid-connection (stdout closed, or
a blocking write that only surfaces its error at flush) is
connection-fatal: it cancels all sessions, which resolves any ask
waiting on a reply that can never be written.
### Wire
`request_permission_params` (`crates/buzz-agent/src/wire.rs`) is
version-aware, keyed on the protocol version negotiated at `initialize`
and stored on `App` for the connection lifetime (never derived from a
later mutable session field). v2 nests the tool call under `subject:
{type: "tool_call", toolCall}` with top-level `title`/`options`; v1 uses
the legacy top-level `toolCall`. No hybrid shape. Both offered options
(`allow_once`, `reject_once`) carry `optionId == kind`, so the client's
`kind`-based selection and this side's `optionId`-based predicate agree
without a lookup table.
### Gate
In each spawned tool task (`crates/buzz-agent/src/agent.rs`) the
sequence is: acquire per-turn permit → argument-shape validation →
broker admission + request + wait → cancellation recheck →
`emit_in_progress` → `mcp.call`. Argument-shape validation is hoisted
out of `mcp.rs::do_call` into `validate_arg_shape` so a malformed
non-object argument is rejected locally without prompting for a call
that could never execute.
Authorization is fail-closed, stated once in `evaluate`: execute IFF
`outcome.outcome == "selected"` **and** the selected `optionId` equals
the offered allow option. Every other shape (reject, cancelled, JSON-RPC
error, malformed, unknown outcome, wrong/unknown option, timeout,
wire-channel closure) denies with a synthetic tool error, and the turn
continues.
### Scope
Only LLM-issued MCP calls are gated. The built-in `load_skill` tool and
`call_hooks` lifecycle calls (`_Stop`, `_PostCompact`) are exempt — they
are not model-issued. `readOnlyHint` is never treated as a security
boundary.
First cut ships `allow_once`/`reject_once` only; session-scoped grants
are deliberately out of scope.
### Related issue
Part of #4938. This PR and
[#5106](https://github.com/block/buzz/pull/5106) jointly implement the
feature: #5106 is the client-side policy engine and permission cards;
this PR is buzz-agent's asking side (`session/request_permission`).
Neither closes #4938 alone.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
632 lines
24 KiB
Rust
632 lines
24 KiB
Rust
//! Production authorization-boundary tests for the `session/request_permission`
|
|
//! surface.
|
|
//!
|
|
//! These drive a real `buzz-agent` subprocess against a fake MCP server and a
|
|
//! capturing LLM, and prove the security invariant end to end: an LLM-issued
|
|
//! MCP tool call reaches the server IFF the client selected the offered
|
|
//! allow-once option, and every other outcome fails closed without invoking the
|
|
//! tool. `fake_mcp` appends each invoked *bare* tool name to `FAKE_MCP_CALL_LOG`
|
|
//! (fired for `_Stop`/`_PostCompact` too), so "reached the tool exactly once"
|
|
//! and "never reached the tool" are both directly observable from disk.
|
|
//!
|
|
//! Timeout/abort/multi-session-cap state invariants live in the broker-seam unit
|
|
//! tests (`src/permission.rs`), which use an injectable deadline and inspect the
|
|
//! private correlation map — neither of which a subprocess can do.
|
|
//!
|
|
//! The subprocess `Harness`, capturing LLM, and `approve_permission` helper are
|
|
//! shared with the other integration suites via `mod common`.
|
|
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
mod common;
|
|
use common::{approve_permission, spawn_capturing_llm, Harness};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// LLM response builders
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
fn openai_text(content: &str) -> Value {
|
|
json!({
|
|
"id": "cc-1", "object": "chat.completion", "model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": { "role": "assistant", "content": content },
|
|
"finish_reason": "stop",
|
|
}],
|
|
})
|
|
}
|
|
|
|
/// One assistant turn issuing `calls`, each `(id, qualified_name, arguments)`.
|
|
fn openai_tool_calls(calls: &[(&str, &str, Value)]) -> Value {
|
|
let tool_calls: Vec<Value> = calls
|
|
.iter()
|
|
.map(|(id, name, args)| {
|
|
json!({
|
|
"id": id, "type": "function",
|
|
"function": { "name": name, "arguments": args.to_string() },
|
|
})
|
|
})
|
|
.collect();
|
|
json!({
|
|
"id": "cc-tc", "object": "chat.completion", "model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": { "role": "assistant", "content": null, "tool_calls": tool_calls },
|
|
"finish_reason": "tool_calls",
|
|
}],
|
|
})
|
|
}
|
|
|
|
fn shell_call(id: &str) -> Value {
|
|
openai_tool_calls(&[(id, "fake__shell", json!({ "command": "ls" }))])
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Permission-response builders + drivers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Bare JSON-RPC response selecting `option_id`.
|
|
fn resp_selected(id: &Value, option_id: &str) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id.clone(),
|
|
"result": { "outcome": { "outcome": "selected", "optionId": option_id } },
|
|
})
|
|
}
|
|
|
|
/// Bare JSON-RPC response carrying an arbitrary `result` shape.
|
|
fn resp_result(id: &Value, result: Value) -> Value {
|
|
json!({ "jsonrpc": "2.0", "id": id.clone(), "result": result })
|
|
}
|
|
|
|
/// Bare JSON-RPC *error* response (id present, `error`, no `result`).
|
|
fn resp_error(id: &Value) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id.clone(),
|
|
"error": { "code": -32601, "message": "method not found" },
|
|
})
|
|
}
|
|
|
|
/// The tool title carried by a permission request, across both wire shapes.
|
|
fn perm_title(req: &Value) -> String {
|
|
let p = &req["params"];
|
|
p["title"]
|
|
.as_str()
|
|
.or_else(|| p["toolCall"]["title"].as_str())
|
|
.or_else(|| p["subject"]["toolCall"]["title"].as_str())
|
|
.unwrap_or("")
|
|
.to_owned()
|
|
}
|
|
|
|
/// Drive one prompt to completion. For each `session/request_permission`,
|
|
/// `decide(&req)` returns `Some(response)` to answer or `None` to leave it
|
|
/// unanswered. Returns the final prompt response and every request seen.
|
|
async fn drive(
|
|
h: &mut Harness,
|
|
sid: &str,
|
|
prompt: &str,
|
|
mut decide: impl FnMut(&Value) -> Option<Value>,
|
|
) -> (Value, Vec<Value>) {
|
|
let p = h
|
|
.send(
|
|
"session/prompt",
|
|
json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": prompt }] }),
|
|
)
|
|
.await;
|
|
let mut requests: Vec<Value> = Vec::new();
|
|
loop {
|
|
let v = h.recv().await;
|
|
if v.get("method") == Some(&json!("session/request_permission")) {
|
|
requests.push(v.clone());
|
|
if let Some(resp) = decide(&v) {
|
|
h.write(resp).await;
|
|
}
|
|
continue;
|
|
}
|
|
if v["id"] == json!(p) {
|
|
return (v, requests);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Session init + call-log helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
fn call_log_path(tag: &str) -> String {
|
|
let p = std::env::temp_dir().join(format!(
|
|
"buzz_perm_calllog_{tag}_{}_{:x}.log",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos()
|
|
));
|
|
let s = p.to_string_lossy().to_string();
|
|
let _ = std::fs::remove_file(&s);
|
|
s
|
|
}
|
|
|
|
/// Invoked tool names recorded by fake_mcp (bare names, one per `tools/call`).
|
|
/// A missing file means zero invocations.
|
|
fn call_log_lines(path: &str) -> Vec<String> {
|
|
std::fs::read_to_string(path)
|
|
.unwrap_or_default()
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|l| !l.is_empty())
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// Initialize + create a session with a single fake MCP server named `fake`,
|
|
/// negotiating `protocol_version` and passing `mcp_env` to the server. `cwd`
|
|
/// controls skill discovery (`.agents/skills`).
|
|
async fn init(
|
|
h: &mut Harness,
|
|
protocol_version: u32,
|
|
cwd: &str,
|
|
mcp_env: &[(&str, &str)],
|
|
) -> String {
|
|
let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp");
|
|
let env: Vec<Value> = mcp_env
|
|
.iter()
|
|
.map(|(k, v)| json!({ "name": k, "value": v }))
|
|
.collect();
|
|
h.send(
|
|
"initialize",
|
|
json!({ "protocolVersion": protocol_version, "clientCapabilities": {} }),
|
|
)
|
|
.await;
|
|
let _ = h.recv().await;
|
|
let servers = if mcp_env.is_empty() {
|
|
json!([])
|
|
} else {
|
|
json!([{ "name": "fake", "command": fake_mcp, "args": [], "env": env }])
|
|
};
|
|
h.send("session/new", json!({ "cwd": cwd, "mcpServers": servers }))
|
|
.await;
|
|
let r = h
|
|
.recv_until(|v| v.get("result").is_some() || v.get("error").is_some())
|
|
.await;
|
|
r["result"]["sessionId"]
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("session/new failed: {r}, stderr={}", h.stderr_text()))
|
|
.to_owned()
|
|
}
|
|
|
|
fn stop_reason(resp: &Value) -> String {
|
|
resp["result"]["stopReason"]
|
|
.as_str()
|
|
.unwrap_or("")
|
|
.to_owned()
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// The authorization boundary
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Exact allow reaches the tool exactly once — and never *before* approval.
|
|
/// The pre-approval check proves the gate precedes the MCP call, not just that
|
|
/// the tally ends at one.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_allow_reaches_tool_exactly_once_and_not_before_approval() {
|
|
let log = call_log_path("allow");
|
|
let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)],
|
|
)
|
|
.await;
|
|
|
|
let log_for_check = log.clone();
|
|
let (resp, requests) = drive(&mut h, &sid, "go", |req| {
|
|
// Before answering, the tool must not have run.
|
|
assert!(
|
|
call_log_lines(&log_for_check).is_empty(),
|
|
"tool invoked BEFORE approval"
|
|
);
|
|
Some(approve_permission(req))
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert_eq!(requests.len(), 1, "exactly one call → exactly one ask");
|
|
assert_eq!(
|
|
call_log_lines(&log),
|
|
vec!["shell"],
|
|
"approved tool reached MCP exactly once"
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// Selecting the offered reject option never invokes the tool, and the model
|
|
/// sees a permission-denied tool error (the turn continues to `end_turn`).
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_reject_option_never_invokes_tool() {
|
|
let log = call_log_path("reject");
|
|
let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("understood")]).await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)],
|
|
)
|
|
.await;
|
|
|
|
let (resp, requests) = drive(&mut h, &sid, "go", |req| {
|
|
Some(resp_selected(&req["id"], "reject_once"))
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert_eq!(requests.len(), 1);
|
|
assert!(
|
|
call_log_lines(&log).is_empty(),
|
|
"rejected tool must never reach MCP"
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// Every non-authorizing response shape fails closed: the tool never runs.
|
|
/// One subprocess per shape keeps the failure attributable.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_adversarial_outcomes_never_invoke_tool() {
|
|
// (tag, response-for-request builder) — the full fail-closed matrix.
|
|
type Shape = (&'static str, fn(&Value) -> Value);
|
|
let shapes: Vec<Shape> = vec![
|
|
("cancelled", |req| {
|
|
resp_result(&req["id"], json!({ "outcome": { "outcome": "cancelled" } }))
|
|
}),
|
|
("jsonrpc_error", |req| resp_error(&req["id"])),
|
|
("missing_outcome", |req| resp_result(&req["id"], json!({}))),
|
|
("empty_outcome", |req| {
|
|
resp_result(&req["id"], json!({ "outcome": {} }))
|
|
}),
|
|
("selected_no_option", |req| {
|
|
resp_result(&req["id"], json!({ "outcome": { "outcome": "selected" } }))
|
|
}),
|
|
("unknown_outcome", |req| {
|
|
resp_result(
|
|
&req["id"],
|
|
json!({ "outcome": { "outcome": "banana", "optionId": "allow_once" } }),
|
|
)
|
|
}),
|
|
("wrong_option_id", |req| {
|
|
resp_selected(&req["id"], "not_an_offered_option")
|
|
}),
|
|
// Structurally malformed *frames* that each still carry a well-formed
|
|
// `selected`/`allow_once` result — the payload would authorize, so only
|
|
// the frame-structure check (wire::classify) stands between them and the
|
|
// tool. These mirror the two broker-seam unit tests
|
|
// (`src/permission.rs::test_frame_with_{result_and_error,non_string_method}_denies_tool`)
|
|
// at the live MCP-log seam, proving the frame gate denies end to end.
|
|
("result_and_error", |req| {
|
|
let mut frame = approve_permission(req);
|
|
frame["error"] = json!({ "code": -32603, "message": "internal" });
|
|
frame
|
|
}),
|
|
("non_string_method", |req| {
|
|
let mut frame = approve_permission(req);
|
|
frame["method"] = json!(7);
|
|
frame
|
|
}),
|
|
];
|
|
|
|
for (tag, build) in shapes {
|
|
let log = call_log_path(tag);
|
|
let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("ok")]).await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)],
|
|
)
|
|
.await;
|
|
|
|
let (resp, requests) = drive(&mut h, &sid, "go", |req| Some(build(req))).await;
|
|
|
|
assert_eq!(
|
|
stop_reason(&resp),
|
|
"end_turn",
|
|
"shape {tag}: turn should continue"
|
|
);
|
|
assert_eq!(requests.len(), 1, "shape {tag}: exactly one ask");
|
|
assert!(
|
|
call_log_lines(&log).is_empty(),
|
|
"shape {tag}: non-authorizing outcome must never reach MCP"
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
}
|
|
|
|
/// A stale/unknown/foreign response id is ignored and does not unblock the live
|
|
/// waiter; the correct id then authorizes exactly once.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_stale_id_ignored_then_real_id_authorizes() {
|
|
let log = call_log_path("staleid");
|
|
let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)],
|
|
)
|
|
.await;
|
|
|
|
let p = h
|
|
.send(
|
|
"session/prompt",
|
|
json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "go" }] }),
|
|
)
|
|
.await;
|
|
|
|
// Wait for the ask.
|
|
let req = h
|
|
.recv_until(|v| v.get("method") == Some(&json!("session/request_permission")))
|
|
.await;
|
|
|
|
// Feed several ignorable responses first: a minted-but-never-issued id, a
|
|
// foreign numeric id, and a null id. None may unblock the real waiter.
|
|
h.write(resp_selected(&json!("perm-9999"), "allow_once"))
|
|
.await;
|
|
h.write(resp_selected(&json!(7), "allow_once")).await;
|
|
h.write(resp_selected(&Value::Null, "allow_once")).await;
|
|
// Give the agent a beat to (wrongly) act on any of them.
|
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
|
assert!(
|
|
call_log_lines(&log).is_empty(),
|
|
"stale/foreign ids must not authorize the pending call"
|
|
);
|
|
|
|
// The real id resolves it exactly once.
|
|
h.write(approve_permission(&req)).await;
|
|
let resp = h.recv_until(|v| v["id"] == json!(p)).await;
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert_eq!(
|
|
call_log_lines(&log),
|
|
vec!["shell"],
|
|
"only the correct id authorizes, exactly once"
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// `session/cancel` while a permission ask is outstanding terminates the turn
|
|
/// promptly, executes nothing, and needs no `cancelled` permission response —
|
|
/// a Buzz client always answers, but a non-Buzz ACP client may violate the spec
|
|
/// by staying silent, and cancellation must not depend on that answer.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_cancel_while_waiting_executes_nothing_without_client_answer() {
|
|
let log = call_log_path("cancelwait");
|
|
let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)],
|
|
)
|
|
.await;
|
|
|
|
let p = h
|
|
.send(
|
|
"session/prompt",
|
|
json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "go" }] }),
|
|
)
|
|
.await;
|
|
|
|
// Wait for the ask, then cancel WITHOUT ever answering the permission.
|
|
let _req = h
|
|
.recv_until(|v| v.get("method") == Some(&json!("session/request_permission")))
|
|
.await;
|
|
h.notify("session/cancel", json!({ "sessionId": sid }))
|
|
.await;
|
|
|
|
let resp = h.recv_until(|v| v["id"] == json!(p)).await;
|
|
assert_eq!(
|
|
stop_reason(&resp),
|
|
"cancelled",
|
|
"cancel resolves the turn without a client permission answer"
|
|
);
|
|
assert!(
|
|
call_log_lines(&log).is_empty(),
|
|
"a cancelled ask must never reach the tool"
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// Two parallel calls each get their own ask (distinct ids); crossed decisions —
|
|
/// deny the first-asked, allow the second-asked — authorize only the allowed
|
|
/// call. Serial admission (`max_parallel_tools=1`) serializes the asks: the
|
|
/// second ask fires only after the first resolves. Which tool is admitted first
|
|
/// is a tokio scheduling detail, so the test denies whichever is asked first and
|
|
/// proves only the allowed (second) call reached MCP — order-agnostic.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_crossed_parallel_decisions_authorize_only_matching_call() {
|
|
let log = call_log_path("crossed");
|
|
// Two distinct registered tools so the call log distinguishes them by name.
|
|
let llm = spawn_capturing_llm(vec![
|
|
openai_tool_calls(&[
|
|
("tc-alpha", "fake__alpha", json!({})),
|
|
("tc-bravo", "fake__bravo", json!({})),
|
|
]),
|
|
openai_text("done"),
|
|
])
|
|
.await;
|
|
let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_PARALLEL_TOOLS", "1")]).await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[
|
|
("FAKE_MCP_NAMED_TOOLS", "alpha,bravo"),
|
|
("FAKE_MCP_CALL_LOG", &log),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
// Deny whichever tool is asked first; allow the second. Ids are distinct.
|
|
let mut seen_ids: Vec<Value> = Vec::new();
|
|
let mut allowed_title = String::new();
|
|
let (resp, requests) = drive(&mut h, &sid, "go", |req| {
|
|
assert!(
|
|
!seen_ids.contains(&req["id"]),
|
|
"each parallel call must get a distinct request id"
|
|
);
|
|
seen_ids.push(req["id"].clone());
|
|
if seen_ids.len() == 1 {
|
|
Some(resp_selected(&req["id"], "reject_once")) // deny the first-asked
|
|
} else {
|
|
allowed_title = perm_title(req);
|
|
Some(approve_permission(req)) // allow the second-asked
|
|
}
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert_eq!(requests.len(), 2, "one ask per parallel call");
|
|
// The call log records bare tool names; the title carries the qualified
|
|
// `<server>__<name>`. Only the allowed (second-asked) call reached MCP.
|
|
let allowed_bare = allowed_title
|
|
.strip_prefix("fake__")
|
|
.expect("qualified title")
|
|
.to_owned();
|
|
assert_eq!(
|
|
call_log_lines(&log),
|
|
vec![allowed_bare],
|
|
"only the allowed (second-asked) call reached MCP; the denied one did not"
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// The built-in `load_skill` tool is not an MCP call and is exempt from the
|
|
/// permission boundary: it executes with no `session/request_permission`.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_load_skill_emits_no_permission_request() {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let cwd = tmp.path();
|
|
let skill_dir = cwd.join(".agents/skills/my-skill");
|
|
std::fs::create_dir_all(&skill_dir).unwrap();
|
|
std::fs::write(
|
|
skill_dir.join("SKILL.md"),
|
|
"---\nname: my-skill\ndescription: A skill\n---\nSKILL_BODY_77\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let llm = spawn_capturing_llm(vec![
|
|
openai_tool_calls(&[("tc-ls", "load_skill", json!({ "name": "my-skill" }))]),
|
|
openai_text("done"),
|
|
])
|
|
.await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
// No MCP server: `load_skill` is a built-in, and skills come from `cwd`.
|
|
let sid = init(&mut h, 1, cwd.to_str().unwrap(), &[]).await;
|
|
|
|
let (resp, requests) = drive(&mut h, &sid, "use my-skill", |_| {
|
|
panic!("load_skill must not trigger a permission request")
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert!(requests.is_empty(), "built-in load_skill is exempt");
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// Lifecycle hooks (`_Stop`, `_PostCompact`) invoke MCP through `call_hooks`,
|
|
/// not the model-issued tool path, so they are exempt: the hook reaches the
|
|
/// server (call log records it) with no permission ask. Here the `_Stop` hook
|
|
/// objects once, forcing a hook invocation the test can observe.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_stop_hook_reaches_mcp_without_permission_ask() {
|
|
let log = call_log_path("stophook");
|
|
// Text turn triggers the _Stop gate → hook objects once → agent loops →
|
|
// second text turn, hook silent → end_turn. No model-issued tool call.
|
|
let llm = spawn_capturing_llm(vec![
|
|
openai_text("premature"),
|
|
openai_text("really done"),
|
|
openai_text("unexpected"),
|
|
])
|
|
.await;
|
|
let mut h = Harness::spawn_with_env(
|
|
&llm.url,
|
|
&[
|
|
("MCP_HOOK_SERVERS", "fake"),
|
|
("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"),
|
|
],
|
|
)
|
|
.await;
|
|
let sid = init(
|
|
&mut h,
|
|
1,
|
|
"/tmp",
|
|
&[
|
|
("FAKE_MCP_TOOL_COUNT", "1"),
|
|
("FAKE_MCP_STOP_HOOK", "1"),
|
|
("FAKE_MCP_STOP_TEXT", "you have open work"),
|
|
("FAKE_MCP_STOP_COUNT", "1"),
|
|
("FAKE_MCP_CALL_LOG", &log),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let (resp, requests) = drive(&mut h, &sid, "go", |_| {
|
|
panic!("a lifecycle hook must not trigger a permission request")
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert!(requests.is_empty(), "_Stop hook is exempt from the ask");
|
|
assert!(
|
|
call_log_lines(&log).contains(&"_Stop".to_owned()),
|
|
"the _Stop hook still reached MCP without an ask; log={:?}",
|
|
call_log_lines(&log)
|
|
);
|
|
h.shutdown().await;
|
|
}
|
|
|
|
/// Under a v2-negotiated connection, the emitted `session/request_permission`
|
|
/// carries the v2 shape (`subject.toolCall`, top-level `title`), never the v1
|
|
/// legacy top-level `toolCall`. Complements the pure v1/v2 builder unit tests
|
|
/// with an end-to-end proof that the negotiated version reaches the wire.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_v2_negotiation_emits_v2_request_shape() {
|
|
let log = call_log_path("v2shape");
|
|
let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await;
|
|
let mut h = Harness::spawn(&llm.url).await;
|
|
let sid = init(
|
|
&mut h,
|
|
2, // negotiate v2
|
|
"/tmp",
|
|
&[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)],
|
|
)
|
|
.await;
|
|
|
|
let (resp, requests) = drive(&mut h, &sid, "go", |req| {
|
|
let params = &req["params"];
|
|
// v2: tool context under `subject.toolCall`, with top-level `title`.
|
|
assert_eq!(params["subject"]["type"], "tool_call", "v2 uses subject");
|
|
assert_eq!(params["subject"]["toolCall"]["title"], "fake__shell");
|
|
assert_eq!(params["title"], "fake__shell", "v2 has top-level title");
|
|
assert!(
|
|
params.get("toolCall").is_none(),
|
|
"v2 must not carry the v1 top-level toolCall"
|
|
);
|
|
Some(approve_permission(req))
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(stop_reason(&resp), "end_turn");
|
|
assert_eq!(requests.len(), 1);
|
|
assert_eq!(call_log_lines(&log), vec!["shell"]);
|
|
h.shutdown().await;
|
|
}
|