Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
8bac83c
add AdapterMode::Protocol and make its authorization gap explicit, no…
nikolasd Sep 11, 2026
a7712d9
reconcile a run's journal against the vendor's own transcript, with t…
nikolasd Sep 11, 2026
e10c256
bridge a can_use_tool control-channel request into the existing appro…
nikolasd Sep 11, 2026
cc2ec6e
drive a real claude subprocess over its control channel and wire the …
nikolasd Sep 11, 2026
0912810
Merge remote-tracking branch 'origin/main' into claude-protocol-spike
nikolasd Sep 11, 2026
525dd55
prove --model reaches this adapter's real launched argv, now that the…
nikolasd Sep 11, 2026
6592583
prove the daemon's real approval callback wiring never touches a TUI run
nikolasd Sep 11, 2026
4662042
chore(extension): refresh bundle from linux-x64 CI
crew-bot[bot] Sep 11, 2026
e57c81b
replace every ADR-0037 citation with the reasoning it stands for
nikolasd Sep 11, 2026
a4849d7
Merge remote-tracking branch 'origin/claude-protocol-spike' into clau…
nikolasd Sep 11, 2026
6e28d4e
chore(extension): refresh bundle from linux-x64 CI
crew-bot[bot] Sep 11, 2026
495d333
render a crew-authored pane for a protocol turn, wired through the ex…
nikolasd Sep 11, 2026
bfaff47
pin permission mode and prompts explicitly, assert the posture took e…
nikolasd Sep 11, 2026
43e48ea
pin the can_use_tool sentinel, drive the initialize handshake, reconc…
nikolasd Sep 11, 2026
6dd9e75
Merge remote-tracking branch 'origin/main' into claude-protocol-spike
nikolasd Sep 11, 2026
ddd08f5
Merge branch 'main' into claude-protocol-spike
nikolasd Sep 11, 2026
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
748 changes: 642 additions & 106 deletions crates/runtime/src/adapter/claude_protocol/adapter.rs

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions crates/runtime/src/adapter/claude_protocol/approval_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ pub(crate) struct PermissionRequest {
pub(crate) request_id: String,
pub(crate) tool_name: String,
pub(crate) input: serde_json::Value,
/// Claude's own id for the specific tool call this request is
/// blocked on (`toolu_...`) -- distinct from `request_id`, which is
/// this control-channel round trip's own correlation id, generated
/// fresh per request. A live capture's final `result` message
/// reports a denied tool call by THIS id, in `permission_denials`,
/// never by `request_id` -- so reconciling a denial against
/// whichever requests this reader actually bridged needs this
/// field, not `request_id`. `None` when absent -- observed present
/// on every real `can_use_tool` request this spike captured, but
/// parsed as optional rather than required, matching this module's
/// own tolerant-parse stance for every other field here.
pub(crate) tool_use_id: Option<String>,
}

/// Parses one `can_use_tool` control-channel message. Tolerant the same
Expand All @@ -52,6 +64,10 @@ pub(crate) fn parse_permission_request(value: &serde_json::Value) -> Option<Perm
.get("input")
.cloned()
.unwrap_or(serde_json::Value::Null),
tool_use_id: request
.get("tool_use_id")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
})
}

Expand Down Expand Up @@ -388,6 +404,7 @@ mod tests {
"subtype": "can_use_tool",
"tool_name": "Write",
"input": { "path": "/tmp/x", "content": "hi" },
"tool_use_id": "toolu_01abc",
},
});
let parsed = parse_permission_request(&value).expect("must parse");
Expand All @@ -397,6 +414,25 @@ mod tests {
parsed.input,
serde_json::json!({ "path": "/tmp/x", "content": "hi" })
);
assert_eq!(parsed.tool_use_id.as_deref(), Some("toolu_01abc"));
}

/// `tool_use_id` is optional, not required -- an absent one parses
/// to `None` rather than failing the whole request, matching this
/// module's own tolerant-parse stance for every other field.
#[test]
fn a_missing_tool_use_id_parses_to_none() {
let value = serde_json::json!({
"type": "control_request",
"request_id": "req-1",
"request": {
"subtype": "can_use_tool",
"tool_name": "Write",
"input": {},
},
});
let parsed = parse_permission_request(&value).expect("must parse");
assert_eq!(parsed.tool_use_id, None);
}

/// A different control-channel message shape (not a permission
Expand Down
116 changes: 116 additions & 0 deletions crates/runtime/src/adapter/claude_protocol/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,67 @@
//! assertable against a `Vec<String>` without spawning anything, the
//! same shape `claude_protocol::approval_bridge`'s parse/build pair and
//! `claude_protocol::reconcile::find_gaps` already take.
//!
//! **Departs from the maintainer-approved launch-posture wording**, which did not
//! name a `--permission-mode`: a live capture found that without one,
//! the effective mode is inherited from the operator's own
//! `~/.claude.json`/settings default, silently, including a value
//! (`bypassPermissions`) that would make `can_use_tool` never reach
//! this adapter's own bridge at all -- permission mode is load-bearing
//! for permissions, and the standing rule is that crew passes anything
//! load-bearing explicitly rather than inherits it. The maintainer
//! ruled on this departure directly: pin it, and pin a permissive
//! value. [`PINNED_PERMISSION_MODE`] is that value.

/// The permission mode this adapter pins explicitly -- `"auto"`, not
/// the `--allow-dangerously-skip-permissions` flag: that flag only
/// makes bypass *available* (its own `--help` text: "without it being
/// enabled by default"), it does not set a mode, so passing it alone
/// would still leave the effective mode inherited from the operator's
/// own settings -- exactly the defect this pin exists to close.
/// `"auto"`'s own semantics are themselves unmeasured by this spike (a
/// finding of their own, not assumed); what is pinned here is the
/// SPELLING crew passes, not a claim about what it permits.
///
/// A named constant, not an inline literal, specifically so
/// [`super::posture`]'s own direct assertion
/// (`system/init`'s reported `permissionMode` equals what crew passed)
/// compares against the SAME value crew actually sent, never a second,
/// independently-typed copy of the same string that could drift from
/// this one.
pub(crate) const PINNED_PERMISSION_MODE: &str = "auto";

/// `--permission-prompts` is pinned too, on the same ground as
/// `PINNED_PERMISSION_MODE` -- this is the flag that decides whether
/// the host is consulted about a permission decision AT ALL, load-
/// bearing for permissions by definition. Pinning it changes nothing
/// today (`"host"` is already the CLI's own default), which is exactly
/// the point: today's default staying the default is not something
/// this adapter should depend on staying true by accident.
pub(crate) const PINNED_PERMISSION_PROMPTS: &str = "host";

/// `--permission-prompt-tool stdio` -- the actual enabler of the
/// `can_use_tool` control-channel requests `claude_protocol::approval_bridge`
/// bridges into crew's own approval ledger. Not documented under this
/// flag's own `--help` entry (that text describes it as naming an MCP
/// tool); found instead by reading the SDK's own source that constructs
/// it -- the Python SDK's `subprocess_cli.py` passes exactly the literal
/// `"stdio"` sentinel whenever a caller supplies a `can_use_tool`
/// callback (`types.py` documents the same shape). The TS SDK is
/// inferred to do the same from the Python side's own comments, not
/// read directly -- this repo ships no TypeScript SDK source to read.
/// Confirmed empirically, not just from source: a live call without
/// this flag produced zero `can_use_tool` control-channel frames across
/// seven calls and two forcing conditions; the identical settings and
/// prompt, with only this flag added, produced a real `can_use_tool`
/// request, and a real reply built from
/// `super::approval_bridge::build_permission_response`'s own unmodified
/// output then let the tool actually execute.
///
/// A named constant for the same reason [`PINNED_PERMISSION_MODE`] is
/// one: nothing else in this adapter needs a second, independently
/// typed copy of this spelling.
pub(crate) const PINNED_PERMISSION_PROMPT_TOOL: &str = "stdio";

/// Builds the argv `claude` is launched with, everything after the
/// binary name itself. `model`, when given, is placed exactly where
Expand All @@ -37,6 +98,12 @@ pub(crate) fn build_argv(model: Option<&str>) -> Vec<String> {
"--verbose".to_string(),
"--setting-sources".to_string(),
"user,project,local".to_string(),
"--permission-mode".to_string(),
PINNED_PERMISSION_MODE.to_string(),
"--permission-prompts".to_string(),
PINNED_PERMISSION_PROMPTS.to_string(),
"--permission-prompt-tool".to_string(),
PINNED_PERMISSION_PROMPT_TOOL.to_string(),
];
if let Some(model) = model {
argv.push("--model".to_string());
Expand Down Expand Up @@ -69,6 +136,55 @@ mod tests {
assert!(!argv.iter().any(|arg| arg.contains("disableAllHooks")));
}

/// The maintainer-ruled departure from the original launch-posture wording,
/// proven directly: `--permission-mode` is always pinned to
/// [`PINNED_PERMISSION_MODE`], never left to be inherited from the
/// operator's own settings.
#[test]
fn permission_mode_is_always_pinned() {
let argv = build_argv(None);
assert!(
argv.windows(2)
.any(|w| w == ["--permission-mode", PINNED_PERMISSION_MODE])
);
}

/// The same pin, for the flag that decides whether the host is
/// consulted at all -- a consequence of the same rule, not a second decision (see this
/// module's own doc comment).
#[test]
fn permission_prompts_is_always_pinned() {
let argv = build_argv(None);
assert!(
argv.windows(2)
.any(|w| w == ["--permission-prompts", PINNED_PERMISSION_PROMPTS])
);
}

/// `--allow-dangerously-skip-permissions` only makes bypass
/// available; it does not pin a mode. Never on this argv, on
/// purpose -- see this module's own doc comment on why it would
/// satisfy the letter of "pass a flag" and none of the actual rule.
#[test]
fn never_passes_the_skip_permissions_flag() {
let argv = build_argv(None);
assert!(!argv.iter().any(|arg| arg.contains("skip-permissions")));
}

/// The sentinel that actually enables `can_use_tool` control
/// requests -- see [`PINNED_PERMISSION_PROMPT_TOOL`]'s own doc
/// comment for how that was confirmed. Proven pinned the same way
/// as the other two flags: always present, never left to whatever
/// `claude`'s own default for this flag might be.
#[test]
fn permission_prompt_tool_is_always_pinned_to_stdio() {
let argv = build_argv(None);
assert!(
argv.windows(2)
.any(|w| w == ["--permission-prompt-tool", PINNED_PERMISSION_PROMPT_TOOL])
);
}

#[test]
fn no_model_flag_when_none_is_given() {
let argv = build_argv(None);
Expand Down
11 changes: 7 additions & 4 deletions crates/runtime/src/adapter/claude_protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
//! or hook-suppressing flag -- the workspace-trust pre-check
//! ([`trust::workspace_trust_accepted`]), the control-channel
//! reader ([`reader::drive_turn`]), the approval bridge
//! ([`approval_bridge`]), and reconciliation against claude's own
//! transcript ([`reconcile::find_gaps`]). Still not built: the pane
//! renderer beyond bare legibility, `resume`/`--continue`, and the
//! other three vendors.
//! ([`approval_bridge`]), reconciliation against claude's own
//! transcript ([`reconcile::find_gaps`]), a crew-rendered pane
//! ([`pane::PaneSupport`]), and an init-time posture assertion
//! ([`posture::version_gate`]). Still not built: `resume`/`--continue`
//! and the other three vendors.

pub(crate) mod adapter;
// `pub`, not `pub(crate)`: `ProtocolApprovalCallback`'s own doc comment
Expand All @@ -26,6 +27,8 @@ pub(crate) mod adapter;
// already set, for a different property).
pub mod approval_bridge;
pub(crate) mod launch;
pub(crate) mod pane;
pub(crate) mod posture;
pub(crate) mod reader;
pub(crate) mod trust;

Expand Down
Loading
Loading