Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/mcp-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
170 changes: 169 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -6684,6 +6684,8 @@ fn release_audit_runtime_checks(root: &Path) -> Result<Vec<ReleaseAuditCheck>, 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()?;
Expand Down Expand Up @@ -7541,6 +7543,33 @@ fn release_audit_runtime_checks(root: &Path) -> Result<Vec<ReleaseAuditCheck>, 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -10958,6 +10997,122 @@ done
})
}

fn mcp_subprocess_proxy_duplicate_initialized_notification_smoke()
-> Result<McpDuplicateInitializedNotificationSmokeReport, AgentKError> {
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::<serde_json::Value>)
.collect::<Result<Vec<_>, _>>()?;
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<McpNotificationBurstSmokeReport, AgentKError> {
const RAW_NOTIFICATION: &str = "DOWNSTREAM_NOTIFICATION_SHOULD_NOT_REFLECT";
Expand Down Expand Up @@ -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()
{
Expand Down