diff --git a/examples/mcp-close-server.sh b/examples/mcp-close-server.sh new file mode 100644 index 0000000..2df5d34 --- /dev/null +++ b/examples/mcp-close-server.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +log_if_configured() { + if [ "${AGENTK_FAKE_MCP_EXEC_LOG:-}" ]; then + printf '%s\n' "$1" >> "$AGENTK_FAKE_MCP_EXEC_LOG" + fi +} + +while IFS= read -r line; do + case "$line" in + *agentk*) log_if_configured "metadata leaked" ;; + esac + + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"agentk-public-close-demo","version":"test"}}}' + ;; + *'"method":"notifications/initialized"'*) + ;; + *'"method":"tools/list"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"demo.close","description":"Accepts a public request and closes stdout before returning a tool response.","inputSchema":{"type":"object","properties":{"input_ref":{"type":"string"}}}}]}}' + ;; + *'"id":3'*'"method":"tools/call"'*) + log_if_configured "close tool called" + exit 0 + ;; + *) + printf '%s\n' '{"jsonrpc":"2.0","id":999,"error":{"code":-32601,"message":"unknown fake request"}}' + ;; + esac +done diff --git a/examples/mcp-close-session.jsonl b/examples/mcp-close-session.jsonl new file mode 100644 index 0000000..402237e --- /dev/null +++ b/examples/mcp-close-session.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}} +{"jsonrpc":"2.0","method":"notifications/initialized","params":{}} +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"demo.close","arguments":{"input_ref":"CLOSE_ARGUMENT_SHOULD_NOT_REFLECT"},"agentk":{"intent":"exercise downstream close after an allowed tool boundary","labels":["trusted"],"capabilities":["tool.invoke:demo.close"]}}} diff --git a/src/lib.rs b/src/lib.rs index 7951197..fa05442 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6543,6 +6543,8 @@ pub fn readiness_report(root: impl AsRef) -> ReadinessReport { check_required_file(&root, "examples/mcp-killer-demo-server.sh"), check_required_file(&root, "examples/mcp-proxy-poisoned-error-session.jsonl"), check_required_file(&root, "examples/mcp-poisoned-error-server.sh"), + check_required_file(&root, "examples/mcp-close-session.jsonl"), + check_required_file(&root, "examples/mcp-close-server.sh"), check_required_file(&root, "examples/mcp-timeout-session.jsonl"), check_required_file(&root, "examples/mcp-timeout-server.sh"), check_required_file(&root, "examples/replay-behavior-overrides.json"), @@ -6678,6 +6680,7 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A let mcp_subprocess_proxy_prompt = mcp_subprocess_proxy_prompt_smoke()?; let mcp_subprocess_proxy_mixed_interop = mcp_subprocess_proxy_mixed_interop_smoke()?; let mcp_public_interop_transcript = mcp_public_interop_transcript_smoke(root)?; + let mcp_public_close_transcript = mcp_public_close_transcript_smoke(root)?; let mcp_public_timeout_transcript = mcp_public_timeout_transcript_smoke(root)?; let mcp_subprocess_proxy_pre_ready_notification = mcp_subprocess_proxy_pre_ready_notification_smoke()?; @@ -7468,6 +7471,31 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A mcp_public_interop_transcript.event_count ), ), + release_audit_check( + "mcp public close transcript", + if mcp_public_close_transcript.descriptor_mediated + && mcp_public_close_transcript.allowed_call_reached_downstream + && mcp_public_close_transcript.close_reported + && mcp_public_close_transcript.metadata_stripped + && mcp_public_close_transcript.raw_argument_not_returned + && mcp_public_close_transcript.raw_argument_not_logged + && mcp_public_close_transcript.event_count >= 2 + { + ReadinessStatus::Pass + } else { + ReadinessStatus::Fail + }, + format!( + "descriptor {}, call {}, close {}, child clean {}, redacted {}, events {}", + mcp_public_close_transcript.descriptor_mediated, + mcp_public_close_transcript.allowed_call_reached_downstream, + mcp_public_close_transcript.close_reported, + mcp_public_close_transcript.metadata_stripped, + mcp_public_close_transcript.raw_argument_not_returned + && mcp_public_close_transcript.raw_argument_not_logged, + mcp_public_close_transcript.event_count + ), + ), release_audit_check( "mcp public timeout transcript", if mcp_public_timeout_transcript.descriptor_mediated @@ -8049,6 +8077,17 @@ struct McpPublicInteropTranscriptSmokeReport { event_count: usize, } +#[derive(Debug)] +struct McpPublicCloseTranscriptSmokeReport { + descriptor_mediated: bool, + allowed_call_reached_downstream: bool, + close_reported: bool, + metadata_stripped: bool, + raw_argument_not_returned: bool, + raw_argument_not_logged: bool, + event_count: usize, +} + #[derive(Debug)] struct McpPublicTimeoutTranscriptSmokeReport { descriptor_mediated: bool, @@ -10703,6 +10742,60 @@ fn mcp_public_interop_transcript_smoke( }) } +fn mcp_public_close_transcript_smoke( + root: &Path, +) -> Result { + const RAW_CLOSE_ARGUMENT: &str = "CLOSE_ARGUMENT_SHOULD_NOT_REFLECT"; + + let input = fs::read_to_string(root.join("examples/mcp-close-session.jsonl"))?; + let execution_log = env::temp_dir().join(format!( + "agentk-public-close-transcript-{}-{}.log", + std::process::id(), + unix_timestamp() + )); + let config = McpSubprocessProxyConfig::new("agent://release-audit", "close-demo", "sh") + .with_args([root + .join("examples/mcp-close-server.sh") + .display() + .to_string()]) + .with_env( + "AGENTK_FAKE_MCP_EXEC_LOG", + execution_log.display().to_string(), + ); + let report = mcp_subprocess_proxy_json_lines(&input, config)?; + let responses = report + .output + .lines() + .map(serde_json::from_str::) + .collect::, _>>()?; + let execution_log_content = fs::read_to_string(&execution_log).unwrap_or_default(); + let _ = fs::remove_file(&execution_log); + let serialized_events = serde_json::to_string(&report.events)?; + + Ok(McpPublicCloseTranscriptSmokeReport { + descriptor_mediated: responses.get(1).is_some_and(|response| { + response["result"]["tools"][0]["agentk"]["mediated"] == serde_json::json!(true) + && response["result"]["tools"][0]["agentk"]["descriptor_hash"] + .as_str() + .is_some_and(|hash| hash.len() == 64) + }), + allowed_call_reached_downstream: execution_log_content.contains("close tool called"), + close_reported: responses.get(2).is_some_and(|response| { + response["id"] == serde_json::json!(3) + && response["error"]["code"] == serde_json::json!(-32003) + && response["error"]["message"] == serde_json::json!("Bad downstream response") + && response["error"]["data"]["detail"] + .as_str() + .is_some_and(|detail| detail.contains("closed stdout before responding")) + }), + metadata_stripped: !execution_log_content.contains("metadata leaked") + && !execution_log_content.contains("agentk"), + raw_argument_not_returned: !report.output.contains(RAW_CLOSE_ARGUMENT), + raw_argument_not_logged: !serialized_events.contains(RAW_CLOSE_ARGUMENT), + event_count: report.events.len(), + }) +} + fn mcp_public_timeout_transcript_smoke( root: &Path, ) -> Result { @@ -16639,6 +16732,21 @@ done assert_eq!(report.event_count, 13); } + #[test] + fn release_audit_public_mcp_close_transcript_reports_sanitized_close() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let report = mcp_public_close_transcript_smoke(root) + .expect("public MCP close transcript smoke should run"); + + assert!(report.descriptor_mediated); + assert!(report.allowed_call_reached_downstream); + assert!(report.close_reported); + assert!(report.metadata_stripped); + assert!(report.raw_argument_not_returned); + assert!(report.raw_argument_not_logged); + assert_eq!(report.event_count, 2); + } + #[test] fn release_audit_public_mcp_timeout_transcript_reports_sanitized_timeout() { let root = Path::new(env!("CARGO_MANIFEST_DIR"));