use anyhow::Context;
use serde_json::{json, Value};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use tempfile::TempDir;
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(())
}
fn read_json_line(
reader: &mut BufReader<std::process::ChildStdout>,
timeout: Duration,
) -> anyhow::Result<Value> {
let start = Instant::now();
loop {
if start.elapsed() > timeout {
anyhow::bail!("timeout waiting for response");
}
let mut line = String::new();
let n = reader.read_line(&mut line)?;
if n == 0 {
anyhow::bail!("EOF from proxy");
}
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.starts_with('{') {
continue;
}
return Ok(serde_json::from_str::<Value>(line)?);
}
}
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())
}
#[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
"#,
)?;
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 reader = BufReader::new(stdout);
let secret = "ghp_assay_fixture_DO_NOT_LEAK_0123456789";
let req = json!({
"jsonrpc": "2.0",
"id": "token-log-fixture",
"method": "tools/call",
"params": {
"name": "assay_check_args",
"arguments": {
"tool": "read_file",
"arguments": {
"path": "/workspace/report.md",
"authorization": secret
},
"policy": "version: \"2.0\"\ntools:\n allow: [\"read_file\"]\nenforcement:\n unconstrained_tools: allow\n"
}
}
});
send_line(&mut stdin, &req)?;
let _ = read_json_line(&mut reader, Duration::from_secs(5))?;
drop(stdin);
let status = wait_child_with_timeout(&mut child, Duration::from_secs(5))?;
assert!(status.success(), "proxy exited with status {status}");
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: {audit}"
);
assert!(
!decisions.contains(secret),
"decision log leaked raw token-like argument: {decisions}"
);
assert!(audit.contains("assay_check_args"));
assert!(decisions.contains("assay_check_args"));
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 reader = BufReader::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 reader, Duration::from_secs(5))?;
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}"
);
let _ = child.kill();
let _ = child.wait();
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 reader = BufReader::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 reader, Duration::from_secs(5))?;
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}"
);
let _ = child.kill();
let _ = child.wait();
Ok(())
}