From bd988a74e6cf20b8c5fe3ca824a7205bcd487658 Mon Sep 17 00:00:00 2001 From: Atomics-hub Date: Wed, 27 May 2026 06:01:02 -0700 Subject: [PATCH] Guard duplicate MCP initialized notifications --- README.md | 2 + docs/architecture.md | 2 +- docs/mcp-proxy.md | 4 +- docs/roadmap.md | 1 + src/lib.rs | 170 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6915b67..ffc7bfa 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,8 @@ Implemented today: network egress and unsafe patch attempts, - subprocess MCP pre-ready notification guards so client notifications cannot bypass lifecycle gating, +- subprocess MCP duplicate-initialized notification guards so lifecycle signals + cannot be replayed downstream after readiness, - downstream subprocess MCP notification-burst handling without raw payload reflection, - downstream subprocess MCP notification-flood bounds without raw payload diff --git a/docs/architecture.md b/docs/architecture.md index 1d8be14..f01e237 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -145,7 +145,7 @@ Blocked MCP tool, resource, and prompt responses also carry compact `denial` sum `agentk fork-replay-behavior` accepts a JSON array of changed hashed output refs and emits a divergence report. Overrides are bound to the recorded step, syscall, and target, and raw output strings are rejected. -`agentk release-audit` packages the local release ritual into one report. It runs readiness, git hygiene checks, formatting, tests, clippy, a fresh demo trace, signature verification with signer summaries, signer-pinning and trusted-signer manifest smoke coverage, brokered secret-handle, secret-reference validation, and secret-store availability smoke tests, MCP taint-flow, subprocess MCP boundary, lifecycle-redaction, initialize-guard, tool/resource/prompt shape guards, bad-response redaction, response-timeout, transport-close, mixed-interop, public interop transcript, resource subscription no-passthrough, pre-ready notification no-passthrough, notification-burst/flood, no-passthrough, config-guard, AgentK metadata-redaction, client-intent hashing, invalid-client-param smoke tests, and denial-summary smoke tests, redacted inspect, replay blocked-rule summaries, fork replay decision summaries, behavior fork replay, and an MCP server smoke test. It does not configure remotes or push. +`agentk release-audit` packages the local release ritual into one report. It runs readiness, git hygiene checks, formatting, tests, clippy, a fresh demo trace, signature verification with signer summaries, signer-pinning and trusted-signer manifest smoke coverage, brokered secret-handle, secret-reference validation, and secret-store availability smoke tests, MCP taint-flow, subprocess MCP boundary, lifecycle-redaction, initialize-guard, tool/resource/prompt shape guards, bad-response redaction, response-timeout, transport-close, mixed-interop, public interop transcript, resource subscription no-passthrough, pre-ready and duplicate-initialized notification no-passthrough, notification-burst/flood, no-passthrough, config-guard, AgentK metadata-redaction, client-intent hashing, invalid-client-param smoke tests, and denial-summary smoke tests, redacted inspect, replay blocked-rule summaries, fork replay decision summaries, behavior fork replay, and an MCP server smoke test. It does not configure remotes or push. ### MCP Proxy MVP diff --git a/docs/mcp-proxy.md b/docs/mcp-proxy.md index 245447a..ba2ec3b 100644 --- a/docs/mcp-proxy.md +++ b/docs/mcp-proxy.md @@ -88,7 +88,9 @@ After readiness, `initialize`, `ping`, `tools/list`, `tools/call`, are the only request methods covered by this proxy. Other MCP request methods are rejected with a sanitized `Method not found` response until they have an explicit AgentK policy contract. The proxy forwards `notifications/initialized` -and the cancellation notification, but drops other notifications. +once after a successful initialize and forwards the cancellation notification +after readiness, but drops duplicate lifecycle notifications and other +notifications. `resources/subscribe` and `resources/unsubscribe` are explicitly unsupported for v0.1 and release-audit verifies that they are not forwarded as passthrough. diff --git a/docs/roadmap.md b/docs/roadmap.md index 684445c..3da44c0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -113,6 +113,7 @@ Status: in progress. - [x] Add release-audit smoke coverage for mixed subprocess MCP interoperability. - [x] Add public MCP interoperability transcript fixtures backed by release-audit. - [x] Add release-audit smoke coverage for pre-ready subprocess MCP notification no-passthrough. +- [x] Add release-audit smoke coverage for duplicate initialized notification no-passthrough. - [x] Add release-audit smoke coverage for downstream notification bursts. - [x] Add release-audit smoke coverage for bounded downstream notification floods. - [x] Add prompt error redaction and malformed prompt result coverage for the subprocess MCP proxy. diff --git a/src/lib.rs b/src/lib.rs index fa05442..daf5288 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2169,7 +2169,7 @@ impl McpSubprocessProxy { method: &str, message: &serde_json::Value, ) -> Result<(), AgentKError> { - if method == "notifications/initialized" && self.initialized { + if method == "notifications/initialized" && self.initialized && !self.ready { self.ready = true; let _ = self.send_json_rpc_message(message); } else if self.ready && mcp_subprocess_proxy_notification_allowed(method) { @@ -6684,6 +6684,8 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A 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()?; + let mcp_subprocess_proxy_duplicate_initialized_notification = + mcp_subprocess_proxy_duplicate_initialized_notification_smoke()?; let mcp_subprocess_proxy_notification_burst = mcp_subprocess_proxy_notification_burst_smoke()?; let mcp_subprocess_proxy_notification_flood = mcp_subprocess_proxy_notification_flood_smoke()?; let mcp_subprocess_proxy_prompt_error = mcp_subprocess_proxy_prompt_error_smoke()?; @@ -7541,6 +7543,33 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A mcp_subprocess_proxy_pre_ready_notification.event_count ), ), + release_audit_check( + "mcp subprocess duplicate initialized notification", + if mcp_subprocess_proxy_duplicate_initialized_notification.first_initialized_forwarded + && mcp_subprocess_proxy_duplicate_initialized_notification + .duplicate_initialized_dropped + && mcp_subprocess_proxy_duplicate_initialized_notification.lifecycle_completed + && mcp_subprocess_proxy_duplicate_initialized_notification + .raw_notification_not_returned + && mcp_subprocess_proxy_duplicate_initialized_notification + .raw_notification_not_logged + { + ReadinessStatus::Pass + } else { + ReadinessStatus::Fail + }, + format!( + "first forwarded {}, duplicate dropped {}, lifecycle {}, returned redacted {}, evidence redacted {}, events {}", + mcp_subprocess_proxy_duplicate_initialized_notification.first_initialized_forwarded, + mcp_subprocess_proxy_duplicate_initialized_notification + .duplicate_initialized_dropped, + mcp_subprocess_proxy_duplicate_initialized_notification.lifecycle_completed, + mcp_subprocess_proxy_duplicate_initialized_notification + .raw_notification_not_returned, + mcp_subprocess_proxy_duplicate_initialized_notification.raw_notification_not_logged, + mcp_subprocess_proxy_duplicate_initialized_notification.event_count + ), + ), release_audit_check( "mcp subprocess notification burst", if mcp_subprocess_proxy_notification_burst.notification_burst_tolerated @@ -8117,6 +8146,16 @@ struct McpPreReadyNotificationSmokeReport { event_count: usize, } +#[derive(Debug)] +struct McpDuplicateInitializedNotificationSmokeReport { + first_initialized_forwarded: bool, + duplicate_initialized_dropped: bool, + lifecycle_completed: bool, + raw_notification_not_returned: bool, + raw_notification_not_logged: bool, + event_count: usize, +} + #[derive(Debug)] struct McpNotificationFloodSmokeReport { notification_flood_bounded: bool, @@ -10958,6 +10997,122 @@ done }) } +fn mcp_subprocess_proxy_duplicate_initialized_notification_smoke() +-> Result { + const RAW_DUPLICATE_NOTIFICATION: &str = "DUPLICATE_INITIALIZED_SHOULD_NOT_REFLECT"; + const DUPLICATE_INITIALIZED_NOTIFICATION_SCRIPT: &str = r#" +while IFS= read -r line; do + case "$line" in + *agentk*|*DUPLICATE_INITIALIZED_SHOULD_NOT_REFLECT*) printf '%s\n' "$line" >> "$1" ;; + esac + case "$line" in + *'"method":"initialize"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"duplicate-initialized-notification","version":"test"}}}' + ;; + *'"method":"notifications/initialized"'*) + case "$line" in + *DUPLICATE_INITIALIZED_SHOULD_NOT_REFLECT*) printf '%s\n' "duplicate initialized forwarded" >> "$1" ;; + *) printf '%s\n' "initialized forwarded" >> "$1" ;; + esac + ;; + *'"method":"ping"'*) + printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}' + ;; + *) + printf '%s\n' '{"jsonrpc":"2.0","id":999,"error":{"code":-32601,"message":"unknown fake request"}}' + ;; + esac +done +"#; + let execution_log = env::temp_dir().join(format!( + "agentk-subprocess-duplicate-initialized-notification-smoke-{}-{}.log", + std::process::id(), + unix_timestamp() + )); + let input = [ + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": MCP_PROTOCOL_VERSION + } + }) + .to_string(), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {} + }) + .to_string(), + serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": { + "reason": RAW_DUPLICATE_NOTIFICATION, + "agentk": { + "secret": RAW_DUPLICATE_NOTIFICATION + } + } + }) + .to_string(), + serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "ping", + "params": {} + }) + .to_string(), + ] + .join("\n"); + let report = mcp_subprocess_proxy_json_lines( + &input, + McpSubprocessProxyConfig::new( + "agent://release-audit", + "duplicate-initialized-notification", + "sh", + ) + .with_args([ + "-c".to_string(), + DUPLICATE_INITIALIZED_NOTIFICATION_SCRIPT.to_string(), + "agentk-duplicate-initialized-notification".to_string(), + execution_log.display().to_string(), + ]), + )?; + 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)?; + let initialized_forward_count = execution_log_content + .lines() + .filter(|line| *line == "initialized forwarded") + .count(); + + Ok(McpDuplicateInitializedNotificationSmokeReport { + first_initialized_forwarded: initialized_forward_count == 1, + duplicate_initialized_dropped: !execution_log_content + .contains("duplicate initialized forwarded"), + lifecycle_completed: responses.len() == 2 + && responses.first().is_some_and(|response| { + response["result"]["serverInfo"]["name"] + == serde_json::json!("duplicate-initialized-notification") + }) + && responses.get(1).is_some_and(|response| { + response["id"] == serde_json::json!(2) + && response["result"] == serde_json::json!({}) + }), + raw_notification_not_returned: !report.output.contains(RAW_DUPLICATE_NOTIFICATION), + raw_notification_not_logged: !execution_log_content.contains(RAW_DUPLICATE_NOTIFICATION) + && !execution_log_content.contains("agentk") + && !serialized_events.contains(RAW_DUPLICATE_NOTIFICATION), + event_count: report.events.len(), + }) +} + fn mcp_subprocess_proxy_notification_burst_smoke() -> Result { const RAW_NOTIFICATION: &str = "DOWNSTREAM_NOTIFICATION_SHOULD_NOT_REFLECT"; @@ -16774,6 +16929,19 @@ done assert_eq!(report.event_count, 0); } + #[test] + fn release_audit_subprocess_mcp_proxy_duplicate_initialized_notification_smoke_drops_payload() { + let report = mcp_subprocess_proxy_duplicate_initialized_notification_smoke() + .expect("subprocess proxy duplicate initialized notification smoke should run"); + + assert!(report.first_initialized_forwarded); + assert!(report.duplicate_initialized_dropped); + assert!(report.lifecycle_completed); + assert!(report.raw_notification_not_returned); + assert!(report.raw_notification_not_logged); + assert_eq!(report.event_count, 0); + } + #[test] fn release_audit_subprocess_mcp_proxy_notification_burst_smoke_tolerates_downstream_notifications() {