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
115 changes: 111 additions & 4 deletions crates/runtime/src/adapter/claude_protocol/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("<unknown tool>")
),
));
};
let explained = outcome.bridged_tool_use_ids.contains(id)
|| outcome.observed_permission_denials.contains(id);
if !explained {
return Err(AdapterError::protocol(
"claude",
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<dyn AdapterEventSink> = 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
Loading