Files
buzz/crates/buzz-agent/tests/bin/fake_mcp.rs
Will Pfleger a1219070fa feat(buzz-agent): gate LLM tool calls on session/request_permission (#5712)
## 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>
2026-08-25 11:49:07 -04:00

378 lines
16 KiB
Rust

//! Tiny fake MCP server for integration tests.
//!
//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by
//! environment variables so tests can simulate misbehavior:
//!
//! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout)
//! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout)
//! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1)
//! FAKE_MCP_HUGE_DESC=1 — every tool description is 100 KB
//! FAKE_MCP_DESC_SIZE=N — every tool description is N bytes (overrides HUGE_DESC)
//! FAKE_MCP_TOOL_DELAY=N — `tools/call` sleeps N seconds before replying
//! (use a large value, e.g. 999, to simulate hang)
//! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result
//! (default: the literal "ok"); grows history
//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block
//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup
//! (for tests that want to verify the child died)
//! FAKE_MCP_SPAWN_GRANDCHILD=1
//! — on `tools/call`, spawn a `sleep 999`
//! grandchild before hanging. Its PID is
//! written to FAKE_MCP_GRANDCHILD_PID_FILE
//! so a test can verify the entire process
//! tree dies on timeout.
//! FAKE_MCP_GRANDCHILD_PID_FILE=path
//! — path to write the grandchild PID to.
//! FAKE_MCP_CANCEL_LOG=path — append each `notifications/cancelled` frame to
//! `path` (one JSON line per notification).
//! FAKE_MCP_CALL_LOG=path — append the tool name of each `tools/call` to
//! `path` (one name per line). Lets a test assert
//! a tool was invoked exactly once, or never — the
//! permission gate's core proof.
//! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool
//! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going")
//! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying
//! (use a large value to simulate hang/timeout)
//! FAKE_MCP_STOP_COUNT=N — `_Stop` returns STOP_TEXT for the first N
//! invocations; empty string thereafter. If
//! unset, every call returns STOP_TEXT.
//! FAKE_MCP_POSTCOMPACT_HOOK=1
//! — expose a `_PostCompact` hook tool
//! FAKE_MCP_POSTCOMPACT_TEXT=text
//! — `_PostCompact` returns this (default: "")
//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell`
//! (registered as `<server>__shell`), taking a
//! `command` string. Lets a test drive the
//! reply guard's recognition of a real,
//! registered shell tool.
//! FAKE_MCP_NAMED_TOOLS=a,b — expose one no-arg tool per comma-separated bare
//! name (each registered as `<server>__<name>`), in
//! addition to any `FAKE_MCP_TOOL_COUNT` tools. Lets
//! a test issue parallel calls to distinctly named
//! tools and tell them apart in `FAKE_MCP_CALL_LOG`.
use std::io::{BufRead, Write};
use serde_json::{json, Value};
fn env_flag(k: &str) -> bool {
std::env::var(k).map(|v| v != "0").unwrap_or(false)
}
fn env_usize(k: &str, default: usize) -> usize {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn env_u64(k: &str, default: u64) -> u64 {
std::env::var(k)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
fn write_response(id: Value, result: Value) {
let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result });
let mut s = serde_json::to_string(&msg).expect("serialize");
s.push('\n');
let mut out = std::io::stdout().lock();
out.write_all(s.as_bytes()).expect("write");
out.flush().expect("flush");
}
fn hang_forever() -> ! {
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
}
}
fn make_tools(
count: usize,
desc: &str,
include_stop_hook: bool,
include_post_compact_hook: bool,
include_shell_tool: bool,
named_tools: &[String],
) -> Vec<Value> {
let mut tools: Vec<Value> = (0..count)
.map(|i| {
json!({
"name": format!("tool_{i}"),
"description": desc,
"inputSchema": { "type": "object", "properties": {} },
})
})
.collect();
for name in named_tools {
tools.push(json!({
"name": name,
"description": "named test tool",
"inputSchema": { "type": "object", "properties": {} },
}));
}
if include_stop_hook {
tools.push(json!({
"name": "_Stop",
"description": "stop hook",
"inputSchema": { "type": "object", "properties": {} },
}));
}
if include_post_compact_hook {
tools.push(json!({
"name": "_PostCompact",
"description": "post compact hook",
"inputSchema": { "type": "object", "properties": {} },
}));
}
if include_shell_tool {
tools.push(json!({
"name": "shell",
"description": "run a shell command",
"inputSchema": {
"type": "object",
"properties": { "command": { "type": "string" } },
"required": ["command"],
},
}));
}
tools
}
fn main() {
// Optional: write our own PID so a test can later check the process is gone.
if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") {
let pid = std::process::id().to_string();
let _ = std::fs::write(&path, pid);
}
let hang_init = env_flag("FAKE_MCP_HANG_INIT");
let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS");
let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1);
// FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set.
let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE")
.ok()
.and_then(|v| v.parse::<usize>().ok())
{
"x".repeat(n)
} else if env_flag("FAKE_MCP_HUGE_DESC") {
"x".repeat(100_000)
} else {
"fake tool".to_owned()
};
let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0);
// Tool-call result text size in bytes (default: the literal "ok"). Lets a
// test grow session history by a controlled amount via a tool result.
let result_size = env_u64("FAKE_MCP_RESULT_SIZE", 0) as usize;
let stop_hook = env_flag("FAKE_MCP_STOP_HOOK");
let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned());
let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0);
// 0 means "unset" → unlimited; any positive value caps the number of
// calls that return STOP_TEXT before flipping to empty string.
let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX);
let mut stop_calls_seen: usize = 0;
let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK");
let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL");
let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default();
// One extra no-arg tool per comma-separated bare name.
let named_tools: Vec<String> = std::env::var("FAKE_MCP_NAMED_TOOLS")
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
// Use a channel-based stdin reader so notifications (which carry no id)
// are captured even while the main thread is sleeping during a tool call.
let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok();
let (tx, rx) = std::sync::mpsc::channel::<(Value, Option<Value>)>();
let cancel_log_for_thread = cancel_log_path.clone();
std::thread::spawn(move || {
let stdin = std::io::stdin();
let lines = stdin.lock().lines();
for line in lines {
let line = match line {
Ok(l) => l,
Err(_) => return,
};
if line.trim().is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let id = msg.get("id").cloned();
// Notifications carry no id. Log cancellations if configured.
if id.is_none() || id == Some(Value::Null) {
if method == "notifications/cancelled" {
if let Some(ref path) = cancel_log_for_thread {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
let _ = writeln!(f, "{}", line.trim());
}
}
}
continue;
}
// Send requests (with id) to the main processing loop.
let _ = tx.send((msg, id));
}
});
while let Ok((msg, id_opt)) = rx.recv() {
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let id = id_opt.unwrap_or(Value::Null);
match method {
"initialize" => {
if hang_init {
hang_forever();
}
write_response(
id,
json!({
"protocolVersion": "2025-06-18",
"capabilities": { "tools": {} },
"serverInfo": { "name": "fake-mcp", "version": "0.0.0" },
}),
);
}
"tools/list" => {
if hang_tools {
hang_forever();
}
write_response(
id,
json!({
"tools": make_tools(
tool_count,
&desc,
stop_hook,
post_compact_hook,
shell_tool,
&named_tools,
)
}),
);
}
"tools/call" => {
// Signal that the request was received (for tests that
// need to wait until the call is in-flight before cancelling).
// Write the request id so tests can correlate with cancel.
if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") {
let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into());
let _ = std::fs::write(&path, id_str);
}
let called_name = msg
.get("params")
.and_then(|p| p.get("name"))
.and_then(Value::as_str)
.unwrap_or("");
// Append every invoked tool name so a test can prove a call
// reached the server exactly once (or never). This fires for
// ALL tools/call, including `_Stop`/`_PostCompact` hooks, so a
// test can also prove hooks are NOT permission-gated by
// observing they still reach the server without an ask.
if let Ok(path) = std::env::var("FAKE_MCP_CALL_LOG") {
use std::io::Write as _;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "{called_name}");
}
}
// Optionally spawn a long-sleeping grandchild so the test
// can verify process-group killing reaches the whole tree.
if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") {
let child = std::process::Command::new("sleep")
.arg("999")
.spawn()
.expect("spawn grandchild");
if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") {
let _ = std::fs::write(&path, child.id().to_string());
}
std::mem::forget(child);
}
if called_name == "_Stop" {
if stop_delay_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs));
}
// Once we exceed the configured count, return empty
// text so the agent treats it as no objection. This
// lets a test exercise the "objected then cleared"
// path without relying on the rejection budget.
let payload = if stop_calls_seen < stop_count_limit {
stop_text.clone()
} else {
String::new()
};
stop_calls_seen = stop_calls_seen.saturating_add(1);
write_response(
id,
json!({
"content": [{ "type": "text", "text": payload }],
"isError": false,
}),
);
continue;
}
if called_name == "_PostCompact" {
write_response(
id,
json!({
"content": [{ "type": "text", "text": post_compact_text }],
"isError": false,
}),
);
continue;
}
if tool_delay_secs > 0 {
std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs));
}
let result_text = if result_size > 0 {
"x".repeat(result_size)
} else {
"ok".to_owned()
};
let content = if env_flag("FAKE_MCP_IMAGE_RESULT") {
json!([
{ "type": "text", "text": result_text },
{ "type": "image", "data": "aW1n", "mimeType": "image/png" },
])
} else {
json!([{ "type": "text", "text": result_text }])
};
write_response(
id,
json!({
"content": content,
"isError": false,
}),
);
}
_ => {
// Unknown method: respond with an error so rmcp doesn't hang.
let err = json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": format!("method not found: {method}") },
});
let mut s = serde_json::to_string(&err).unwrap();
s.push('\n');
let mut out = std::io::stdout().lock();
let _ = out.write_all(s.as_bytes());
let _ = out.flush();
}
}
}
}