diff --git a/README.md b/README.md index 3d5b0c1..6915b67 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,7 @@ Implemented today: through trace evidence, - subprocess MCP invalid client-parameter guards before empty identifiers can reach downstream servers, +- compact denial summaries on blocked MCP tool, resource, and prompt responses, - subprocess MCP response timeout handling for hung downstream servers, - subprocess MCP transport-close handling for child exits and broken pipes, - a runnable MCP killer demo that blocks poisoned-output exfiltration and @@ -437,8 +438,9 @@ Implemented today: transport-close checks, mixed interop, public interop transcripts, resource subscription no-passthrough, pre-ready notification guards, notification-burst/flood checks, config and metadata guards, - client-intent redaction, invalid client-parameter guards, no-passthrough - checks, the MCP shim eval, inspect, and MCP server smoke checks. + client-intent redaction, invalid client-parameter guards, denial summaries, + no-passthrough checks, the MCP shim eval, inspect, and MCP server smoke + checks. Not implemented yet: diff --git a/docs/architecture.md b/docs/architecture.md index ddb3029..1d8be14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -137,13 +137,15 @@ Replay modes: `agentk trace-inspect` is the human review path. It verifies the hash chain, summarizes signature status, groups blocked events by policy rule, groups boundary events by syscall and evidence-ref type, and prints one compact row per event. Known hash evidence refs such as `args_sha256`, `descriptor_sha256`, and `response_sha256` are preserved. Any raw input ref is replaced with a fresh `input_sha256` ref in the inspection report. +Blocked MCP tool, resource, and prompt responses also carry compact `denial` summaries at the response boundary. Those summaries surface verdict, policy rule, reason, syscall, target, and any missing capability without requiring reviewers to dig through the full nested event body. + `agentk replay` records deterministic `stub_output_sha256` evidence refs for allowed `model.call`, `tool.invoke`, and `network.send` events. Blocked side effects stay blocked, do not get stub outputs, and are summarized by policy rule. `agentk fork-replay` compares the recorded log against another policy and reports both per-event changes and transition counts such as `deny:rule->allow:rule`. This makes policy drift reviewable without manually counting every changed event. `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, and invalid-client-param 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 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/roadmap.md b/docs/roadmap.md index 7bdfa93..6aa1b46 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -99,6 +99,8 @@ Status: in progress. - [x] Hash client-provided AgentK intent metadata in subprocess MCP evidence. - [x] Reject empty subprocess MCP tool, resource, and prompt identifiers before forwarding. +- [x] Surface compact denial summaries in blocked MCP tool/resource/prompt + responses. - [x] Add an operator contract for subprocess MCP proxy boundaries. - [x] Default-deny unsupported subprocess MCP request methods instead of generic passthrough. - [x] Add release-audit smoke coverage for unsupported subprocess MCP no-passthrough. @@ -133,6 +135,7 @@ Status: in progress. - [x] Record stub outputs for model/tool/network syscalls. - [x] Summarize blocked policy rules in deterministic replay output. - [x] Summarize decision transitions in fork replay output. +- [x] Surface blocked MCP denial details directly at the response boundary. - [x] Fork replay with changed model/tool behavior. - [x] Emit divergence reports. diff --git a/src/lib.rs b/src/lib.rs index 001651f..791e422 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4305,10 +4305,23 @@ fn jsonrpc_downstream_tool_error( ) } +fn agentk_denial_summary(event: &Event) -> serde_json::Value { + serde_json::json!({ + "verdict": event.decision.verdict, + "rule": &event.decision.rule, + "reason": &event.decision.reason, + "missing_capability": event.decision.missing_capability.as_deref(), + "syscall": event.syscall.kind.to_string(), + "target": &event.syscall.target, + }) +} + fn jsonrpc_agentk_blocked_resource_read( id: serde_json::Value, report: McpResourceReadReport, ) -> serde_json::Value { + let denial = agentk_denial_summary(&report.event); + jsonrpc_error( id, -32006, @@ -4320,6 +4333,7 @@ fn jsonrpc_agentk_blocked_resource_read( "mediated": true, "downstream_forwarded": false, "server_executed": false, + "denial": denial, "read": report } })), @@ -4347,6 +4361,8 @@ fn jsonrpc_agentk_blocked_prompt_get( id: serde_json::Value, report: McpPromptGetReport, ) -> serde_json::Value { + let denial = agentk_denial_summary(&report.event); + jsonrpc_error( id, -32009, @@ -4358,6 +4374,7 @@ fn jsonrpc_agentk_blocked_prompt_get( "mediated": true, "downstream_forwarded": false, "server_executed": false, + "denial": denial, "get": report } })), @@ -4867,15 +4884,18 @@ fn subprocess_mcp_proxy_prompts_list_response( fn subprocess_mcp_proxy_blocked_tool_result(report: McpProxyReport) -> serde_json::Value { let target = report.event.syscall.target.clone(); let rule = report.event.decision.rule.clone(); + let reason = report.event.decision.reason.clone(); + let denial = agentk_denial_summary(&report.event); serde_json::json!({ "content": [ { "type": "text", - "text": format!("AgentK blocked tool.invoke:{target} via {rule}") + "text": format!("AgentK blocked tool.invoke:{target} via {rule}: {reason}") } ], "structuredContent": { + "denial": denial, "invoke": report, "downstream_forwarded": false, "server_executed": false @@ -5263,15 +5283,18 @@ fn in_memory_mcp_proxy_blocked_tool_result( ) -> serde_json::Value { let target = report.invoke.event.syscall.target.clone(); let rule = report.invoke.event.decision.rule.clone(); + let reason = report.invoke.event.decision.reason.clone(); + let denial = agentk_denial_summary(&report.invoke.event); serde_json::json!({ "content": [ { "type": "text", - "text": format!("AgentK blocked tool.invoke:{target} via {rule}") + "text": format!("AgentK blocked tool.invoke:{target} via {rule}: {reason}") } ], "structuredContent": { + "denial": denial, "invoke": report.invoke, "response_record": report.response_record, "server_executed": report.server_executed @@ -6907,6 +6930,7 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A && mcp_subprocess_proxy.allowed_forwarded && mcp_subprocess_proxy.response_recorded && mcp_subprocess_proxy.denied_blocked + && mcp_subprocess_proxy.denial_summary_visible && mcp_subprocess_proxy.denied_not_forwarded && mcp_subprocess_proxy.metadata_stripped && mcp_subprocess_proxy.raw_descriptor_not_logged @@ -6917,11 +6941,12 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A ReadinessStatus::Fail }, format!( - "descriptor {}, allowed {}, response {}, denied {}, child clean {}, redacted {}, events {}", + "descriptor {}, allowed {}, response {}, denied {}, summary {}, child clean {}, redacted {}, events {}", mcp_subprocess_proxy.descriptor_mediated, mcp_subprocess_proxy.allowed_forwarded, mcp_subprocess_proxy.response_recorded, mcp_subprocess_proxy.denied_blocked, + mcp_subprocess_proxy.denial_summary_visible, mcp_subprocess_proxy.denied_not_forwarded && mcp_subprocess_proxy.metadata_stripped, mcp_subprocess_proxy.raw_descriptor_not_logged && mcp_subprocess_proxy.raw_response_not_logged, @@ -7308,6 +7333,7 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A && mcp_subprocess_proxy_resource.allowed_forwarded && mcp_subprocess_proxy_resource.response_recorded && mcp_subprocess_proxy_resource.denied_blocked + && mcp_subprocess_proxy_resource.denial_summary_visible && mcp_subprocess_proxy_resource.denied_not_forwarded && mcp_subprocess_proxy_resource.metadata_stripped && mcp_subprocess_proxy_resource.raw_descriptor_not_logged @@ -7319,11 +7345,12 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A ReadinessStatus::Fail }, format!( - "descriptor {}, allowed {}, response {}, denied {}, child clean {}, redacted {}, events {}", + "descriptor {}, allowed {}, response {}, denied {}, summary {}, child clean {}, redacted {}, events {}", mcp_subprocess_proxy_resource.resource_descriptor_mediated, mcp_subprocess_proxy_resource.allowed_forwarded, mcp_subprocess_proxy_resource.response_recorded, mcp_subprocess_proxy_resource.denied_blocked, + mcp_subprocess_proxy_resource.denial_summary_visible, mcp_subprocess_proxy_resource.denied_not_forwarded && mcp_subprocess_proxy_resource.metadata_stripped, mcp_subprocess_proxy_resource.raw_descriptor_not_logged @@ -7338,6 +7365,7 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A && mcp_subprocess_proxy_prompt.allowed_forwarded && mcp_subprocess_proxy_prompt.response_recorded && mcp_subprocess_proxy_prompt.denied_blocked + && mcp_subprocess_proxy_prompt.denial_summary_visible && mcp_subprocess_proxy_prompt.denied_not_forwarded && mcp_subprocess_proxy_prompt.metadata_stripped && mcp_subprocess_proxy_prompt.raw_descriptor_not_logged @@ -7349,11 +7377,12 @@ fn release_audit_runtime_checks(root: &Path) -> Result, A ReadinessStatus::Fail }, format!( - "descriptor {}, allowed {}, response {}, denied {}, child clean {}, redacted {}, events {}", + "descriptor {}, allowed {}, response {}, denied {}, summary {}, child clean {}, redacted {}, events {}", mcp_subprocess_proxy_prompt.prompt_descriptor_mediated, mcp_subprocess_proxy_prompt.allowed_forwarded, mcp_subprocess_proxy_prompt.response_recorded, mcp_subprocess_proxy_prompt.denied_blocked, + mcp_subprocess_proxy_prompt.denial_summary_visible, mcp_subprocess_proxy_prompt.denied_not_forwarded && mcp_subprocess_proxy_prompt.metadata_stripped, mcp_subprocess_proxy_prompt.raw_descriptor_not_logged @@ -7762,6 +7791,7 @@ struct McpSubprocessProxySmokeReport { allowed_forwarded: bool, response_recorded: bool, denied_blocked: bool, + denial_summary_visible: bool, denied_not_forwarded: bool, metadata_stripped: bool, raw_descriptor_not_logged: bool, @@ -7928,6 +7958,7 @@ struct McpResourceSmokeReport { allowed_forwarded: bool, response_recorded: bool, denied_blocked: bool, + denial_summary_visible: bool, denied_not_forwarded: bool, metadata_stripped: bool, raw_descriptor_not_logged: bool, @@ -7942,6 +7973,7 @@ struct McpPromptSmokeReport { allowed_forwarded: bool, response_recorded: bool, denied_blocked: bool, + denial_summary_visible: bool, denied_not_forwarded: bool, metadata_stripped: bool, raw_descriptor_not_logged: bool, @@ -8254,6 +8286,19 @@ fn mcp_subprocess_proxy_smoke(root: &Path) -> Result