mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +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>
280 lines
10 KiB
Rust
280 lines
10 KiB
Rust
//! Shared subprocess test harness for the buzz-agent ACP integration suites.
|
|
//!
|
|
//! Every integration test file is its own crate, so this module is included
|
|
//! with `mod common;` in each and compiles once per binary — each binary uses
|
|
//! only the subset it needs, hence the module-wide `dead_code` allow.
|
|
//!
|
|
//! It drives a real `buzz-agent` child over the ACP wire against a fake LLM
|
|
//! (`CapturingLlm`, which records each request body) and answers the
|
|
//! `session/request_permission` surface (`approve_permission`, selecting the
|
|
//! offered `allow_once` option by `kind`, never a hardcoded `optionId`).
|
|
|
|
#![allow(dead_code)]
|
|
|
|
use std::collections::VecDeque;
|
|
use std::process::Stdio;
|
|
use std::sync::{Arc, Mutex as StdMutex};
|
|
use std::time::Duration;
|
|
|
|
use serde_json::{json, Value};
|
|
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::TcpListener;
|
|
use tokio::sync::Mutex;
|
|
|
|
pub struct CapturingLlm {
|
|
pub url: String,
|
|
pub captured: Arc<Mutex<Vec<Value>>>,
|
|
}
|
|
|
|
pub async fn spawn_capturing_llm(responses: Vec<Value>) -> CapturingLlm {
|
|
spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await
|
|
}
|
|
|
|
/// Like `spawn_capturing_llm` but each canned response carries its own HTTP
|
|
/// status, so a test can serve a real provider rejection (e.g. a context-window
|
|
/// 400) instead of only success bodies.
|
|
pub async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
|
let queue = Arc::new(Mutex::new(VecDeque::from(responses)));
|
|
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
|
|
let cap2 = captured.clone();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let (mut sock, _) = match listener.accept().await {
|
|
Ok(p) => p,
|
|
Err(_) => return,
|
|
};
|
|
let queue = queue.clone();
|
|
let captured = cap2.clone();
|
|
tokio::spawn(async move {
|
|
let mut buf = Vec::new();
|
|
let mut tmp = [0u8; 8192];
|
|
// Read until headers complete.
|
|
while !buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
|
match sock.read(&mut tmp).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
if buf.len() > 4_000_000 {
|
|
return;
|
|
}
|
|
}
|
|
// Parse Content-Length and read body.
|
|
let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4;
|
|
let headers = &buf[..header_end];
|
|
let mut body_len = 0usize;
|
|
for line in headers.split(|b| *b == b'\n') {
|
|
let line = std::str::from_utf8(line).unwrap_or("");
|
|
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") {
|
|
body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0);
|
|
}
|
|
}
|
|
while buf.len() < header_end + body_len {
|
|
match sock.read(&mut tmp).await {
|
|
Ok(0) | Err(_) => return,
|
|
Ok(n) => buf.extend_from_slice(&tmp[..n]),
|
|
}
|
|
}
|
|
if let Ok(req) = serde_json::from_slice::<Value>(&buf[header_end..]) {
|
|
captured.lock().await.push(req);
|
|
}
|
|
let (status, body) = queue
|
|
.lock()
|
|
.await
|
|
.pop_front()
|
|
.unwrap_or_else(|| (200, json!({ "error": "no canned response" })));
|
|
let body_s = serde_json::to_string(&body).unwrap();
|
|
let reason = match status {
|
|
200 => "OK",
|
|
400 => "Bad Request",
|
|
_ => "Error",
|
|
};
|
|
let resp = format!(
|
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body_s.len(), body_s,
|
|
);
|
|
let _ = sock.write_all(resp.as_bytes()).await;
|
|
let _ = sock.shutdown().await;
|
|
});
|
|
}
|
|
});
|
|
CapturingLlm { url, captured }
|
|
}
|
|
|
|
pub struct Harness {
|
|
child: tokio::process::Child,
|
|
stdin: tokio::process::ChildStdin,
|
|
stdout: BufReader<tokio::process::ChildStdout>,
|
|
stderr: Arc<StdMutex<String>>,
|
|
next_id: i64,
|
|
}
|
|
|
|
impl Harness {
|
|
pub async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self {
|
|
let bin = env!("CARGO_BIN_EXE_buzz-agent");
|
|
let mut cmd = tokio::process::Command::new(bin);
|
|
cmd.env("BUZZ_AGENT_PROVIDER", "openai")
|
|
.env("OPENAI_COMPAT_API_KEY", "test")
|
|
.env("OPENAI_COMPAT_MODEL", "fake-model")
|
|
.env("OPENAI_COMPAT_BASE_URL", base_url)
|
|
.env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5")
|
|
.env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5")
|
|
.env("BUZZ_AGENT_MAX_ROUNDS", "8")
|
|
.env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2");
|
|
for (k, v) in extra {
|
|
cmd.env(k, v);
|
|
}
|
|
cmd.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true);
|
|
let mut child = cmd.spawn().expect("spawn buzz-agent");
|
|
let stdin = child.stdin.take().unwrap();
|
|
let stdout = BufReader::new(child.stdout.take().unwrap());
|
|
let stderr = child.stderr.take().unwrap();
|
|
let stderr_buf = Arc::new(StdMutex::new(String::new()));
|
|
let stderr_out = Arc::clone(&stderr_buf);
|
|
tokio::spawn(async move {
|
|
let mut reader = BufReader::new(stderr);
|
|
let mut line = String::new();
|
|
loop {
|
|
line.clear();
|
|
let n = match reader.read_line(&mut line).await {
|
|
Ok(n) => n,
|
|
Err(_) => break,
|
|
};
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
if let Ok(mut out) = stderr_out.lock() {
|
|
out.push_str(&line);
|
|
}
|
|
}
|
|
});
|
|
Self {
|
|
child,
|
|
stdin,
|
|
stdout,
|
|
stderr: stderr_buf,
|
|
next_id: 1,
|
|
}
|
|
}
|
|
|
|
pub async fn spawn(base_url: &str) -> Self {
|
|
Self::spawn_with_env(base_url, &[]).await
|
|
}
|
|
|
|
pub async fn send(&mut self, method: &str, params: Value) -> i64 {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }))
|
|
.await;
|
|
id
|
|
}
|
|
|
|
pub async fn notify(&mut self, method: &str, params: Value) {
|
|
self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params }))
|
|
.await;
|
|
}
|
|
|
|
pub async fn write(&mut self, msg: Value) {
|
|
let mut s = serde_json::to_string(&msg).unwrap();
|
|
s.push('\n');
|
|
self.stdin.write_all(s.as_bytes()).await.unwrap();
|
|
self.stdin.flush().await.unwrap();
|
|
}
|
|
|
|
pub async fn recv(&mut self) -> Value {
|
|
let mut line = String::new();
|
|
let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line))
|
|
.await
|
|
.expect("recv timeout")
|
|
.expect("read line");
|
|
assert!(n > 0, "agent EOF; stderr={}", self.stderr_text());
|
|
serde_json::from_str(&line).expect("non-JSON line")
|
|
}
|
|
|
|
pub async fn recv_until<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
|
|
loop {
|
|
let v = self.recv().await;
|
|
if pred(&v) {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Like `recv_until`, but auto-approves any `session/request_permission`
|
|
/// seen while waiting. Tests that exercise tool execution, not the
|
|
/// permission boundary (that lives in `permission_boundary.rs`), must
|
|
/// approve a model-issued tool call so it reaches the server.
|
|
pub async fn recv_until_approving<F: FnMut(&Value) -> bool>(&mut self, mut pred: F) -> Value {
|
|
loop {
|
|
let v = self.recv().await;
|
|
if v.get("method") == Some(&json!("session/request_permission")) {
|
|
let resp = approve_permission(&v);
|
|
self.write(resp).await;
|
|
continue;
|
|
}
|
|
if pred(&v) {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn shutdown(mut self) {
|
|
drop(self.stdin);
|
|
let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await;
|
|
let _ = self.child.start_kill();
|
|
}
|
|
|
|
pub fn stderr_text(&self) -> String {
|
|
self.stderr.lock().map(|s| s.clone()).unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
pub 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",
|
|
}],
|
|
})
|
|
}
|
|
|
|
pub fn openai_tool_call(id: &str, name: &str, args: Value) -> Value {
|
|
json!({
|
|
"id": "cc-2", "object": "chat.completion", "model": "fake-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant", "content": null,
|
|
"tool_calls": [{
|
|
"id": id, "type": "function",
|
|
"function": { "name": name, "arguments": args.to_string() },
|
|
}],
|
|
},
|
|
"finish_reason": "tool_calls",
|
|
}],
|
|
})
|
|
}
|
|
|
|
/// Select the offered option whose `kind == "allow_once"` and return the
|
|
/// `session/request_permission` response. Mirrors buzz-acp's answering side,
|
|
/// which selects by `kind`, never by a hardcoded `optionId`. Centralizing this
|
|
/// means a future option-id rename can't silently turn allow into a denial.
|
|
pub fn approve_permission(request: &Value) -> Value {
|
|
let option_id = request["params"]["options"]
|
|
.as_array()
|
|
.and_then(|opts| opts.iter().find(|o| o["kind"] == "allow_once"))
|
|
.and_then(|o| o["optionId"].as_str())
|
|
.expect("request must offer an allow_once option");
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": request["id"],
|
|
"result": { "outcome": { "outcome": "selected", "optionId": option_id } },
|
|
})
|
|
}
|