use anyhow::Context;
use serde_json::{json, Value};
use std::io::{self, BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdout, Command, ExitStatus, Stdio};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::thread;
use std::time::{Duration, Instant};
use tempfile::TempDir;
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
fn exe_name(name: &str) -> String {
if cfg!(windows) {
format!("{name}.exe")
} else {
name.to_string()
}
}
fn bin_path(bin: &str) -> anyhow::Result<PathBuf> {
let env_key_underscore = format!("CARGO_BIN_EXE_{}", bin.replace('-', "_"));
let env_key_hyphen = format!("CARGO_BIN_EXE_{bin}");
if let Ok(p) = std::env::var(&env_key_underscore).or_else(|_| std::env::var(&env_key_hyphen)) {
return Ok(PathBuf::from(p));
}
let target_dir = if let Ok(td) = std::env::var("CARGO_TARGET_DIR") {
PathBuf::from(td)
} else {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest
.parent()
.and_then(|p| p.parent())
.context("failed to resolve workspace root from CARGO_MANIFEST_DIR")?;
workspace_root.join("target")
};
let candidate = target_dir.join("debug").join(exe_name(bin));
Ok(candidate)
}
fn send_line(stdin: &mut dyn Write, v: &Value) -> anyhow::Result<()> {
let s = serde_json::to_string(v)?;
stdin.write_all(s.as_bytes())?;
stdin.write_all(b"\n")?;
stdin.flush()?;
Ok(())
}
struct JsonLines {
rx: mpsc::Receiver<io::Result<String>>,
}
impl JsonLines {
fn new(stdout: ChildStdout) -> Self {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
if tx.send(Ok(line)).is_err() {
break;
}
}
Err(err) => {
let _ = tx.send(Err(err));
break;
}
}
}
});
Self { rx }
}
fn next_json(&mut self, timeout: Duration, what: &str) -> anyhow::Result<Value> {
let deadline = Instant::now() + timeout;
loop {
let now = Instant::now();
if now >= deadline {
anyhow::bail!(
"timed out after {timeout:?} waiting for {what}: no JSON response line arrived"
);
}
let remaining = deadline.saturating_duration_since(now);
let line = match self.rx.recv_timeout(remaining) {
Ok(Ok(line)) => line,
Ok(Err(err)) => {
return Err(anyhow::Error::new(err)
.context(format!("read error while waiting for {what}")))
}
Err(RecvTimeoutError::Timeout) => anyhow::bail!(
"timed out after {timeout:?} waiting for {what}: the proxy wrote no response line"
),
Err(RecvTimeoutError::Disconnected) => {
anyhow::bail!("EOF from proxy while waiting for {what}")
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.starts_with('{') {
continue;
}
return serde_json::from_str::<Value>(line)
.with_context(|| format!("malformed JSON line while waiting for {what}: {line}"));
}
}
}
fn read_json_line(
child: &mut Child,
lines: &mut JsonLines,
timeout: Duration,
what: &str,
) -> anyhow::Result<Value> {
lines.next_json(timeout, what).inspect_err(|_| {
let _ = child.kill();
let _ = child.wait();
})
}
#[cfg(unix)]
#[test]
fn json_lines_deadline_holds_against_a_flood_of_non_json_lines() -> anyhow::Result<()> {
let timeout = Duration::from_secs(1);
let mut child = Command::new("yes")
.arg("not-json")
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let mut lines = JsonLines::new(child.stdout.take().expect("stdout"));
let start = Instant::now();
let err = lines
.next_json(timeout, "a response that never comes")
.expect_err("a flood of non-JSON lines must not satisfy the read");
let elapsed = start.elapsed();
let _ = child.kill();
let _ = child.wait();
assert!(
elapsed < timeout * 5,
"the deadline did not bound the read: gave up only after {elapsed:?} (timeout was {timeout:?})"
);
assert!(
err.to_string().contains("timed out"),
"expected a timeout failure, got: {err}"
);
Ok(())
}
fn wait_child_with_timeout(child: &mut Child, timeout: Duration) -> anyhow::Result<ExitStatus> {
let start = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
return Ok(status);
}
if start.elapsed() > timeout {
let _ = child.kill();
let status = child.wait()?;
anyhow::bail!(
"child did not exit within {:?}; killed with status {status}",
timeout
);
}
std::thread::sleep(Duration::from_millis(25));
}
}
fn extract_structured_contract(resp: &Value) -> Option<&Value> {
resp.get("result")
.and_then(|r| {
r.get("structuredContent")
.or_else(|| r.get("structured_content"))
})
.or_else(|| {
resp.get("payload")
.and_then(|p| p.get("result"))
.and_then(|r| {
r.get("structuredContent")
.or_else(|| r.get("structured_content"))
})
})
}
fn extract_error_code(resp: &Value) -> Option<String> {
extract_structured_contract(resp)
.and_then(|c| c.get("error_code"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn extract_tool_payload(resp: &Value) -> anyhow::Result<Value> {
let text = resp
.get("result")
.and_then(|r| r.get("content"))
.and_then(|c| c.as_array())
.and_then(|c| c.first())
.and_then(|c| c.get("text"))
.and_then(|v| v.as_str())
.with_context(|| format!("response has no result.content[0].text: {resp}"))?;
serde_json::from_str::<Value>(text)
.with_context(|| format!("result.content[0].text is not JSON: {text}"))
}
fn mask_secret(rendered: &str, secret: &str) -> String {
rendered.replace(secret, "<REDACTED-FIXTURE-TOKEN>")
}
#[test]
fn owasp_mcp01_token_args_do_not_leak_to_proxy_logs() -> anyhow::Result<()> {
let assay = bin_path("assay")?;
let server = bin_path("assay-mcp-server")?;
assert!(assay.exists(), "missing binary: {}", assay.display());
assert!(server.exists(), "missing binary: {}", server.display());
let tmp = TempDir::new()?;
let policy_path = tmp.path().join("proxy-policy.yaml");
let policy_root = tmp.path().join("policy-root");
let audit_log = tmp.path().join("audit.ndjson");
let decision_log = tmp.path().join("decisions.ndjson");
std::fs::create_dir_all(&policy_root)?;
std::fs::write(
&policy_path,
r#"
version: "2.0"
name: "owasp-mcp01-token-log-fixture"
tools:
allow: ["assay_check_args"]
enforcement:
unconstrained_tools: allow
"#,
)?;
std::fs::write(
policy_root.join("read-file.yaml"),
r#"
version: "2.0"
name: "owasp-mcp01-inner"
tools:
allow: ["read_file"]
enforcement:
unconstrained_tools: allow
"#,
)?;
let mut child = Command::new(&assay)
.args([
"mcp",
"wrap",
"--policy",
policy_path.to_string_lossy().as_ref(),
"--event-source",
"assay://tests/owasp-mcp01",
"--audit-log",
audit_log.to_string_lossy().as_ref(),
"--decision-log",
decision_log.to_string_lossy().as_ref(),
"--",
server.to_string_lossy().as_ref(),
"--policy-root",
policy_root.to_string_lossy().as_ref(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("failed to spawn {}", assay.display()))?;
let mut stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let mut lines = JsonLines::new(stdout);
let secret = "ghp_assay_fixture_DO_NOT_LEAK_0123456789";
let call = |id: &str, policy: &str| {
json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": "assay_check_args",
"arguments": {
"tool": "read_file",
"arguments": {
"path": "/workspace/report.md",
"authorization": secret
},
"policy": policy
}
}
})
};
send_line(&mut stdin, &call("token-log-allowed", "read-file.yaml"))?;
let allowed_resp = read_json_line(
&mut child,
&mut lines,
RESPONSE_TIMEOUT,
"the tools/call response to request token-log-allowed (the normal, policy-resolving path)",
)?;
send_line(
&mut stdin,
&call("token-log-missing", "does-not-exist.yaml"),
)?;
let error_resp = read_json_line(
&mut child,
&mut lines,
RESPONSE_TIMEOUT,
"the tools/call response to request token-log-missing (the unresolvable-policy path)",
)?;
drop(stdin);
let status = wait_child_with_timeout(&mut child, Duration::from_secs(5))?;
assert!(status.success(), "proxy exited with status {status}");
let allowed_rendered = mask_secret(&allowed_resp.to_string(), secret);
let allowed_payload = extract_tool_payload(&allowed_resp)?;
assert_eq!(
allowed_payload.get("allowed"),
Some(&Value::Bool(true)),
"expected the policy to be evaluated and allow the call, got {allowed_rendered}"
);
assert_eq!(
allowed_resp
.get("result")
.and_then(|r| r.get("isError"))
.and_then(|v| v.as_bool()),
Some(false),
"expected a non-error result on the normal path, got {allowed_rendered}"
);
let error_rendered = mask_secret(&error_resp.to_string(), secret);
let error_payload = extract_tool_payload(&error_resp)?;
assert_eq!(
error_payload
.get("error")
.and_then(|e| e.get("code"))
.and_then(|v| v.as_str()),
Some("E_POLICY_NOT_FOUND"),
"expected E_POLICY_NOT_FOUND on the error path, got {error_rendered}"
);
for resp in [&allowed_resp, &error_resp] {
let rendered = resp.to_string();
assert!(
!rendered.contains(secret),
"response leaked raw token-like argument: {}",
mask_secret(&rendered, secret)
);
}
let audit = std::fs::read_to_string(&audit_log)?;
let decisions = std::fs::read_to_string(&decision_log)?;
assert!(
!audit.contains(secret),
"audit log leaked raw token-like argument: {}",
mask_secret(&audit, secret)
);
assert!(
!decisions.contains(secret),
"decision log leaked raw token-like argument: {}",
mask_secret(&decisions, secret)
);
assert!(audit.contains("assay_check_args"));
assert!(decisions.contains("assay_check_args"));
for id in ["token-log-allowed", "token-log-missing"] {
assert!(
audit.contains(id),
"audit log is missing the record for request {id}: {}",
mask_secret(&audit, secret)
);
assert!(
decisions.contains(id),
"decision log is missing the record for request {id}: {}",
mask_secret(&decisions, secret)
);
}
Ok(())
}
#[test]
fn e2e_wrap_denies_wildcard_contains() -> anyhow::Result<()> {
let assay = bin_path("assay")?;
let server = bin_path("assay-mcp-server")?;
assert!(assay.exists(), "missing binary: {}", assay.display());
assert!(server.exists(), "missing binary: {}", server.display());
let tmp = TempDir::new()?;
let policy_path = tmp.path().join("proxy-policy.yaml");
let policy_root = tmp.path().join("policy-root");
std::fs::create_dir_all(&policy_root)?;
std::fs::write(
&policy_path,
r#"
version: "2.0"
name: "e2e-proxy"
tools:
allow: ["*"]
deny: ["exec*", "*sh", "*kill*"]
enforcement:
unconstrained_tools: allow
"#,
)?;
let mut child = Command::new(&assay)
.args([
"mcp",
"wrap",
"--policy",
policy_path.to_string_lossy().as_ref(),
"--",
server.to_string_lossy().as_ref(),
"--policy-root",
policy_root.to_string_lossy().as_ref(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("failed to spawn {}", assay.display()))?;
let mut stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let mut lines = JsonLines::new(stdout);
let req = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "skill_check", "arguments": {} }
});
send_line(&mut stdin, &req)?;
let resp = read_json_line(
&mut child,
&mut lines,
RESPONSE_TIMEOUT,
"the tools/call response to the denied skill_check request",
)?;
let code = extract_error_code(&resp).unwrap_or_default();
assert!(
code == "E_TOOL_DENIED" || code == "MCP_TOOL_DENIED" || code == "E_TOOL_NOT_ALLOWED",
"expected deny-ish error_code, got '{code}'. resp={resp}"
);
drop(stdin);
let status = wait_child_with_timeout(&mut child, Duration::from_secs(5))?;
assert!(status.success(), "proxy exited with status {status}");
Ok(())
}
#[test]
fn e2e_wrap_denies_schema_violation() -> anyhow::Result<()> {
let assay = bin_path("assay")?;
let server = bin_path("assay-mcp-server")?;
assert!(assay.exists(), "missing binary: {}", assay.display());
assert!(server.exists(), "missing binary: {}", server.display());
let tmp = TempDir::new()?;
let policy_path = tmp.path().join("proxy-policy.yaml");
let policy_root = tmp.path().join("policy-root");
std::fs::create_dir_all(&policy_root)?;
std::fs::write(
&policy_path,
r#"
version: "2.0"
name: "e2e-schema"
tools:
allow: ["read_file"]
schemas:
read_file:
type: object
additionalProperties: false
properties:
path:
type: string
pattern: "^/workspace/.*"
minLength: 1
maxLength: 4096
required: ["path"]
enforcement:
unconstrained_tools: deny
"#,
)?;
let mut child = Command::new(&assay)
.args([
"mcp",
"wrap",
"--policy",
policy_path.to_string_lossy().as_ref(),
"--",
server.to_string_lossy().as_ref(),
"--policy-root",
policy_root.to_string_lossy().as_ref(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()?;
let mut stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let mut lines = JsonLines::new(stdout);
let req = json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": { "name": "read_file", "arguments": { "path": "/etc/passwd" } }
});
send_line(&mut stdin, &req)?;
let resp = read_json_line(
&mut child,
&mut lines,
RESPONSE_TIMEOUT,
"the tools/call response to the schema-violating read_file request",
)?;
let code = extract_error_code(&resp).unwrap_or_default();
assert!(
code == "E_ARG_SCHEMA" || code == "MCP_ARG_CONSTRAINT",
"expected schema/constraint error_code, got '{code}'. resp={resp}"
);
drop(stdin);
let status = wait_child_with_timeout(&mut child, Duration::from_secs(5))?;
assert!(status.success(), "proxy exited with status {status}");
Ok(())
}