From ae13933520d87466524caa33f4a4b22ee40a6857 Mon Sep 17 00:00:00 2001 From: Nikolas Demiridis Date: Sat, 12 Sep 2026 00:49:25 +0300 Subject: [PATCH 1/2] split a missing tool_use_id from an unexplained denial in the ledger-reconciliation check A permission_denials entry with no tool_use_id at all was falling into the same "no corresponding entry ... the host was never consulted" branch as a genuinely unexplained denial. The two are different problems: the first is a shape/parse issue (nothing to correlate against either set), the second is the actual sentinel-failure signal. Conflating them names a cause with no evidence behind it, and would make a future CLI release that drops tool_use_id fail every denial in every repo with an ordinary deny rule while pointing debugging at the wrong flag -- the same blast radius as the two-way check this rework replaced. Both branches still fail closed; only the message differs now. Added the missing-tool_use_id case to the classify_line parse test, plus a dedicated end-to-end test asserting the shape message appears and the "host was never consulted" claim does not. --- .../src/adapter/claude_protocol/reader.rs | 115 +++++++++++++++++- 1 file changed, 111 insertions(+), 4 deletions(-) diff --git a/crates/runtime/src/adapter/claude_protocol/reader.rs b/crates/runtime/src/adapter/claude_protocol/reader.rs index 265659fa..c502616d 100644 --- a/crates/runtime/src/adapter/claude_protocol/reader.rs +++ b/crates/runtime/src/adapter/claude_protocol/reader.rs @@ -604,10 +604,32 @@ where // permission decisions is not one this adapter can // vouch for having driven correctly. for denial in &permission_denials { - let explained = denial.tool_use_id.as_deref().is_some_and(|id| { - outcome.bridged_tool_use_ids.contains(id) - || outcome.observed_permission_denials.contains(id) - }); + // Split deliberately: a missing `tool_use_id` is a + // *shape* problem (this entry cannot be correlated + // against either set at all), never a claim about + // the sentinel -- conflating the two would name a + // cause with no evidence for it, and would make a + // future CLI release that drops this field fail + // every denial in every repo with an ordinary deny + // rule while pointing whoever debugs it at the wrong + // flag. Both branches still fail closed; only the + // message differs. + let Some(id) = denial.tool_use_id.as_deref() else { + return Err(AdapterError::protocol( + "claude", + "start", + format!( + "claude's own result reported a permission denial for {} with \ + no tool_use_id at all, so it cannot be correlated against this \ + turn's own approval ledger or its observed \ + system/permission_denied notices -- the denial's shape, not the \ + host's participation, is what is unaccounted for here", + denial.tool_name.as_deref().unwrap_or("") + ), + )); + }; + let explained = outcome.bridged_tool_use_ids.contains(id) + || outcome.observed_permission_denials.contains(id); if !explained { return Err(AdapterError::protocol( "claude", @@ -721,6 +743,32 @@ mod tests { ); } + /// A `permission_denials` entry missing `tool_use_id` entirely + /// parses to `None` rather than failing the whole line -- this is + /// the shape [`drive_turn`]'s own ledger-reconciliation check must + /// treat as an uncorrelatable entry, not as evidence the host was + /// never consulted (see the dedicated end-to-end test for that + /// distinction). + #[test] + fn a_permission_denial_missing_tool_use_id_parses_to_none() { + let value = serde_json::json!({ + "type": "result", + "subtype": "success", + "permission_denials": [ + {"tool_name": "Bash", "tool_input": {}}, + ], + }); + assert_eq!( + classify_line(&value), + StreamLine::TurnComplete { + permission_denials: vec![PermissionDenial { + tool_name: Some("Bash".to_string()), + tool_use_id: None, + }], + } + ); + } + /// `control_response`'s own `request_id`, nested under `response` -- /// the same nesting the reply this reader itself writes uses. #[test] @@ -1414,6 +1462,65 @@ mod tests { db.shutdown().await.ok(); } + /// A `permission_denials` entry with NO `tool_use_id` at all still + /// fails the turn closed, but the message must say this is a shape + /// problem (nothing to correlate against), never claim the host was + /// never consulted -- that claim has no evidence behind it here, and + /// a message that made it would misdirect debugging toward the + /// sentinel flag for what could just as well be a future CLI release + /// dropping this field. + #[tokio::test] + async fn a_permission_denial_with_no_tool_use_id_fails_the_turn_with_a_shape_message() { + let (mut test_side, adapter_stdout) = tokio::io::duplex(4096); + let (adapter_stdin, _stdin_capture) = tokio::io::duplex(4096); + + let script = concat!( + r#"{"type":"result","subtype":"success","permission_denials":"#, + r#"[{"tool_name":"Bash"}]}"#, + "\n", + ); + test_side.write_all(script.as_bytes()).await.unwrap(); + drop(test_side); + + let sink: Arc = Arc::new(RecordingSink { + events: StdArc::new(parking_lot::Mutex::new(Vec::new())), + }); + let state_dir = tempfile::TempDir::new().unwrap(); + let db = StdArc::new( + DatabaseHandle::start(state_dir.path().join("runtime.db")) + .await + .unwrap(), + ); + let project_id = ProjectId::new(); + let callback = ProtocolApprovalCallback::new(); + let approval_service = noop_approval_service(&db, project_id); + + let result = drive_turn( + adapter_stdout, + adapter_stdin, + &sink, + &approval_service, + &callback, + ids(), + None, + "hello", + ) + .await; + + let err = result.expect_err("a denial with no tool_use_id must still fail the turn"); + assert_eq!(err.error_code(), crate::adapter::AdapterErrorCode::Protocol); + assert!(err.detail().contains("Bash")); + assert!(err.detail().contains("no tool_use_id at all")); + assert!( + !err.detail().contains("the host was never consulted"), + "must not claim the host was never consulted when the actual problem is a missing \ + correlation field: {}", + err.detail() + ); + + db.shutdown().await.ok(); + } + /// The real recorded shape, as a fixture rather than an inline /// literal -- `fixtures/adapters/claude-protocol/permission-denied-frame.jsonl` /// is the actual live capture (scrubbed) this reader's own From c68c29db3ca6306d0823a2d5691fd4f9484fca47 Mon Sep 17 00:00:00 2001 From: Nikolas Demiridis Date: Sat, 12 Sep 2026 00:58:34 +0300 Subject: [PATCH 2/2] scrub our own harness's socket directory out of the ask-rule-no-control-frame fixture messaging_socket_path's port was already placeholder-scrubbed (/tmp/cc-socks/0.sock, consistent with session_id/uuid/cwd on the same line) -- no real socket path was ever committed, and the original scrub pass did not miss it. What remained was the directory itself, /tmp/cc-socks/, which is our own agent harness's cross-session socket location, not a value the capture produced. Incidental to what this fixture proves (the calibration call's own permissionMode/ permission_denials shape), worth removing so a later reader doesn't wonder whether our own tooling's socket layout mattered to that null result. Replaced with a neutral, self-documenting placeholder in the style already used for the other scrubbed fields on that line. --- .../adapters/claude-protocol/ask-rule-no-control-frame.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/adapters/claude-protocol/ask-rule-no-control-frame.jsonl b/fixtures/adapters/claude-protocol/ask-rule-no-control-frame.jsonl index 782634d6..7b7e6557 100644 --- a/fixtures/adapters/claude-protocol/ask-rule-no-control-frame.jsonl +++ b/fixtures/adapters/claude-protocol/ask-rule-no-control-frame.jsonl @@ -1,4 +1,4 @@ -{"type":"system","subtype":"init","cwd":"/workspace/crew","session_id":"00000000-0000-4000-8000-000000000010","tools":["Bash","Read","Write"],"mcp_servers":[],"model":"claude-sonnet-5","permissionMode":"default","apiKeySource":"none","claude_code_version":"2.1.268","output_style":"default","capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"00000000-0000-4000-8000-000000000011","memory_paths":{"auto":"/workspace/crew/.claude-memory/"},"messaging_socket_path":"/tmp/cc-socks/0.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"} +{"type":"system","subtype":"init","cwd":"/workspace/crew","session_id":"00000000-0000-4000-8000-000000000010","tools":["Bash","Read","Write"],"mcp_servers":[],"model":"claude-sonnet-5","permissionMode":"default","apiKeySource":"none","claude_code_version":"2.1.268","output_style":"default","capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"00000000-0000-4000-8000-000000000011","memory_paths":{"auto":"/workspace/crew/.claude-memory/"},"messaging_socket_path":"/tmp/scrubbed-crew-session/messaging.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"} {"type":"assistant","message":{"model":"claude-sonnet-5","id":"msg_000000000000000000000001","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_00000000000000000000000002","name":"Bash","input":{"command":"echo fixture-marker","description":"Echo the requested string"},"caller":{"type":"direct"}}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000010","uuid":"00000000-0000-4000-8000-000000000012","timestamp":"2026-01-01T00:00:00.000Z","request_id":"req_000000000000000000000001"} {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_00000000000000000000000002","type":"tool_result","content":"fixture-marker","is_error":false}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000010","uuid":"00000000-0000-4000-8000-000000000013","timestamp":"2026-01-01T00:00:01.000Z","tool_use_result":{"stdout":"fixture-marker","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false}} {"type":"assistant","message":{"model":"claude-sonnet-5","id":"msg_000000000000000000000002","type":"message","role":"assistant","content":[{"type":"text","text":"fixture-marker"}]},"parent_tool_use_id":null,"session_id":"00000000-0000-4000-8000-000000000010","uuid":"00000000-0000-4000-8000-000000000014","timestamp":"2026-01-01T00:00:02.000Z","request_id":"req_000000000000000000000002"}