From 42d54bb4259fd06f51e994b77922bf8e6a4112bd Mon Sep 17 00:00:00 2001 From: Valentin Yanakiev Date: Sun, 1 Nov 2026 04:24:20 +0200 Subject: [PATCH 1/3] capabilities: the broker answers a harness's initialize asking another MCP version with its own 2025-06-18, as MCP's version negotiation says (decision 0065 slice two, U9b1c) Claude Code opens with initialize asking 2025-11-25, and the broker refused any version but its own, so a real Claude seat dropped the server and never saw the tools it was granted (the controller's real-harness check, R1). MCP 2025-06-18's lifecycle says a server that does not support the version asked answers with one it does (https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). The broker now greets any string version with its own 2025-06-18 result and holds the session to it: a method that version does not admit still refuses, every bound stays, and an initialize with a missing or non-string version still refuses as invalid. The harness-facing version refusal had no other consumer and goes; a child server off 2025-06-18 keeps its cause. MB3's text says so. The new enabled/negotiation.rs drives the real binary: a harness asking 2025-11-25 is answered 2025-06-18, as one asking 2025-06-18 is, lists the granted tools, is refused an unadmitted method and a non-string version exactly, and completes a call whose retained response opens through inspect. The commission's design and tasks for U9b1c ride with this commit. Co-Authored-By: Claude Opus 5.5 --- crates/brokkr-cli/src/broker/rpc.rs | 10 +-- crates/brokkr-cli/src/broker/rpc/calls.rs | 10 ++- .../src/broker/rpc/calls/request.rs | 13 +-- crates/brokkr-cli/src/broker/rpc/tests.rs | 15 ++-- .../tests/capability_broker/enabled.rs | 4 +- .../capability_broker/enabled/negotiation.rs | 82 +++++++++++++++++++ .../design.md | 22 ++++- .../specs/mcp-capability-broker/spec.md | 16 +++- .../tasks.md | 10 ++- quality/file-lines.txt | 11 +-- 10 files changed, 156 insertions(+), 37 deletions(-) create mode 100644 crates/brokkr-cli/tests/capability_broker/enabled/negotiation.rs diff --git a/crates/brokkr-cli/src/broker/rpc.rs b/crates/brokkr-cli/src/broker/rpc.rs index 2a5424355..508b62505 100644 --- a/crates/brokkr-cli/src/broker/rpc.rs +++ b/crates/brokkr-cli/src/broker/rpc.rs @@ -120,7 +120,9 @@ impl Display for Fatal { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { match self { Fatal::Failed(failure) => failure.fmt(formatter), - Fatal::Unsupported => Refused::Version.fmt(formatter), + Fatal::Unsupported => { + formatter.write_str("MCP protocol version is not supported by this broker") + } Fatal::Missing(tool) => { write!(formatter, "MCP server does not offer granted tool '{tool}'") } @@ -141,7 +143,6 @@ enum Refused { Limit, Invalid, Active, - Version, Budget, } @@ -150,7 +151,7 @@ impl Refused { fn code(&self) -> i64 { match self { Refused::Method => -32601, - Refused::Ungranted { .. } | Refused::Version => -32602, + Refused::Ungranted { .. } => -32602, Refused::Limit | Refused::Invalid | Refused::Active => -32600, Refused::Budget => -32603, } @@ -168,9 +169,6 @@ impl Display for Refused { Refused::Limit => formatter.write_str("MCP request exceeds the broker limit"), Refused::Invalid => formatter.write_str("MCP tool call request is invalid"), Refused::Active => formatter.write_str("MCP broker already has an active call"), - Refused::Version => { - formatter.write_str("MCP protocol version is not supported by this broker") - } Refused::Budget => formatter.write_str("capability evidence budget is exhausted"), } } diff --git a/crates/brokkr-cli/src/broker/rpc/calls.rs b/crates/brokkr-cli/src/broker/rpc/calls.rs index e4f5da3bd..8e519ef45 100644 --- a/crates/brokkr-cli/src/broker/rpc/calls.rs +++ b/crates/brokkr-cli/src/broker/rpc/calls.rs @@ -2,7 +2,8 @@ //! CR3, SC4), which `serve` takes up once its server has initialized //! (U6f1b). The harness's //! frames are read whole, at most 1 MiB and 64 deep, and each is checked -//! before anything is recorded: initialize at MB3's version, ping and the +//! before anything is recorded: initialize, answered at MB3's version +//! whatever version it asks for (U9b1c), ping and the //! filtered listing are answered here, a cancellation of the active call //! is passed on, notifications are taken, and any other method refused. A //! tools/call naming a tool in seat-record's vocabulary within SC4's 256 @@ -449,8 +450,7 @@ impl<'s, W: Write + AsFd, E: Read + AsFd> Session<'s, '_, '_, W, E> { Err((id, refused)) => return self.refuse(id.as_ref(), &refused), }; match request { - Request::Initialize(id, version) if version == VERSION => self.greet(&id), - Request::Initialize(id, _) => self.refuse(Some(&id), &Refused::Version), + Request::Initialize(id) => self.greet(&id), Request::Ping(id) => self.tell(Some(&id), Reply::Result(json!({}))), Request::List(id) => self.listing(&id), Request::Call(id, called) => self.call(id, called), @@ -464,7 +464,9 @@ impl<'s, W: Write + AsFd, E: Read + AsFd> Session<'s, '_, '_, W, E> { } /// Answer the harness's initialize `id` as the plan's server, at MB3's - /// version and the dialect's pinned one, offering tools alone. + /// version and the dialect's pinned one, offering tools alone: MB3's + /// version whatever version it asked, as MCP 2025-06-18's lifecycle + /// negotiates one (U9b1c), and the session held to it. fn greet(&mut self, id: &Id) -> Result<(), Ended> { let server = json!({"name": self.plan.server, "version": self.plan.dialect.version}); let tools = json!({"tools": {}}); diff --git a/crates/brokkr-cli/src/broker/rpc/calls/request.rs b/crates/brokkr-cli/src/broker/rpc/calls/request.rs index 461780e4a..f111e3df8 100644 --- a/crates/brokkr-cli/src/broker/rpc/calls/request.rs +++ b/crates/brokkr-cli/src/broker/rpc/calls/request.rs @@ -31,12 +31,13 @@ struct Asked { _keyed: Keyed, } -/// A harness's initialize parameters: the version it asks for, beside -/// whatever else it says. +/// A harness's initialize parameters: the version it asks for, which must +/// be a string, whatever it names, beside whatever else it says. MB3's +/// version answers any (U9b1c), as MCP's version negotiation has it. #[derive(Deserialize)] struct Greeting { #[serde(rename = "protocolVersion")] - version: String, + _version: String, #[serde(flatten)] _keyed: Keyed, } @@ -68,7 +69,7 @@ pub(super) struct Called { /// A harness request, checked whole. pub(super) enum Request { - Initialize(Id, String), + Initialize(Id), Ping(Id), List(Id), Call(Id, Called), @@ -81,7 +82,7 @@ impl Request { /// The id the harness would be answered by, none for a notification. pub(super) fn id(&self) -> Option<&Id> { match self { - Request::Initialize(id, _) | Request::Ping(id) | Request::List(id) => Some(id), + Request::Initialize(id) | Request::Ping(id) | Request::List(id) => Some(id), Request::Call(id, _) | Request::Other(id) => Some(id), Request::Cancel(_) | Request::Told => None, } @@ -105,7 +106,7 @@ pub(super) fn decoded(bytes: &[u8]) -> Result, Refused)> { } (_, None) => Ok(Request::Told), ("initialize", Some(id)) => match Greeting::deserialize(params) { - Ok(greeting) => Ok(Request::Initialize(id, greeting.version)), + Ok(_) => Ok(Request::Initialize(id)), Err(_) => Err((Some(id), Refused::Invalid)), }, ("ping", Some(id)) => Ok(Request::Ping(id)), diff --git a/crates/brokkr-cli/src/broker/rpc/tests.rs b/crates/brokkr-cli/src/broker/rpc/tests.rs index 7e3398305..b923787ac 100644 --- a/crates/brokkr-cli/src/broker/rpc/tests.rs +++ b/crates/brokkr-cli/src/broker/rpc/tests.rs @@ -274,11 +274,6 @@ fn each_cause_reads_in_mb3s_words_and_latches_through_the_ledgers_own() { -32600, "MCP broker already has an active call", ), - ( - Refused::Version, - -32602, - "MCP protocol version is not supported by this broker", - ), ]; for (refused, code, text) in refused { assert_eq!( @@ -1030,7 +1025,6 @@ fn exchange(frames: Vec, answers: Vec) -> impl FnOnce(&mut Harness #[test] fn the_harness_is_answered_here_and_its_granted_call_forwarded_once_recorded() { - let version = "MCP protocol version is not supported by this broker"; let greeting = json!({"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "cap-library-docs", "version": "1.4.2"}}); let asked = vec![ @@ -1043,13 +1037,15 @@ fn the_harness_is_answered_here_and_its_granted_call_forwarded_once_recorded() { request(json!("p"), "ping", json!({})), request(json!(1), "tools/list", json!({})), request(json!(2), "resources/list", json!({})), + // MCP's negotiation: another version is answered with MB3's. request( json!(3), "initialize", - json!({"protocolVersion": "2024-11-05"}), + json!({"protocolVersion": "2025-11-25"}), ), json!({"jsonrpc": "2.0", "id": 4, "method": "initialize"}), request(json!(5), "initialize", json!(["2025-06-18"])), + request(json!(6), "initialize", json!({"protocolVersion": 7})), request( json!("c"), "tools/call", @@ -1057,7 +1053,7 @@ fn the_harness_is_answered_here_and_its_granted_call_forwarded_once_recorded() { ), ]; let answers = vec![ - result(&json!(0), greeting), + result(&json!(0), greeting.clone()), result(&json!("p"), json!({})), result(&json!(1), json!({"tools": granted()})), refusal( @@ -1065,9 +1061,10 @@ fn the_harness_is_answered_here_and_its_granted_call_forwarded_once_recorded() { -32601, "MCP method is not admitted by this broker", ), - refusal(json!(3), -32602, version), + result(&json!(3), greeting), refusal(json!(4), -32600, "MCP tool call request is invalid"), refusal(json!(5), -32600, "MCP tool call request is invalid"), + refusal(json!(6), -32600, "MCP tool call request is invalid"), echoed("c", "lookup"), ]; // The call, asked last, is answered once the server has answered it. diff --git a/crates/brokkr-cli/tests/capability_broker/enabled.rs b/crates/brokkr-cli/tests/capability_broker/enabled.rs index f6a82b19e..e7f2c4104 100644 --- a/crates/brokkr-cli/tests/capability_broker/enabled.rs +++ b/crates/brokkr-cli/tests/capability_broker/enabled.rs @@ -11,7 +11,8 @@ //! Linux only, as a server box is; macOS's refusal is `capability_broker`'s //! `the_compile_fence_refuses_an_unused_mcp_grant_only_where_it_stands`. //! Children: `transport` (42.2), `matrix`, `reach`, `sites`, `boxes` and -//! `windows` (46.1), `lifetime` (46.2's two worktrees), `dispatch` (ruling 21). +//! `windows` (46.1), `lifetime` (46.2's two worktrees), `dispatch` (ruling 21), +//! `negotiation` (U9b1c's 46.3). //! //! The engine runs here as an operator runs it, with no wrapper: `brokkr //! driver` sets `no_new_privs` on itself before it spawns a brokered harness @@ -25,6 +26,7 @@ mod boxes; mod dispatch; mod lifetime; mod matrix; +mod negotiation; mod reach; mod sites; mod transport; diff --git a/crates/brokkr-cli/tests/capability_broker/enabled/negotiation.rs b/crates/brokkr-cli/tests/capability_broker/enabled/negotiation.rs new file mode 100644 index 000000000..c197fbe83 --- /dev/null +++ b/crates/brokkr-cli/tests/capability_broker/enabled/negotiation.rs @@ -0,0 +1,82 @@ +//! Task 46.3 (U9b1c; MB3): MCP's version negotiation through the real +//! binary. A harness asking a version other than MB3's, as Claude Code asks +//! 2025-11-25, is answered with the broker's own 2025-06-18 result, as MCP +//! 2025-06-18's lifecycle requires of a server that does not support the +//! version asked +//! (), +//! and the session is held to 2025-06-18: a method it does not admit still +//! refuses, and an initialize with no string version still refuses as before. + +use serde_json::{json, Value}; + +use super::World; + +/// What the harness says to its broker, one frame a line: a greeting asking +/// 2025-11-25, its listing, a method 2025-06-18 does not admit, a greeting +/// asking MB3's own version, one with no string version, and one call of +/// `resolve`. +const FRAMES: &str = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"harness","version":"1"}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} +{"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}} +{"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}} +{"jsonrpc":"2.0","id":5,"method":"initialize","params":{"protocolVersion":20251125,"capabilities":{}}} +{"jsonrpc":"2.0","id":"c","method":"tools/call","params":{"name":"resolve","arguments":{}}} +"#; + +/// The broker's error answer to the harness's request `id`. +fn refused(id: u8, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} + +/// A harness asking 2025-11-25 is answered 2025-06-18, as one asking +/// 2025-06-18 is; it lists exactly the granted tools and completes a call +/// whose response is retained and opens through `inspect`; a method +/// 2025-06-18 does not admit refuses, and so does a version that is no +/// string, each with its exact cause. +#[test] +fn a_harness_asking_another_version_is_answered_mb3s_and_completes_a_granted_call() { + let Some(world) = World::new() else { + return; + }; + world.frames(FRAMES); + let ended = world.completes(); + assert_eq!(world.broker_code(), Some(0)); + let greeting = |id: u8| { + json!({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "cap-library-docs", "version": "1.4.2"}}}) + }; + let tools = json!([{"name": "resolve", "inputSchema": {"type": "object"}}, + {"name": "read", "inputSchema": {"type": "object"}}]); + let heard = std::fs::read_to_string(world.bin("heard")).unwrap(); + let heard: Vec = heard + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let said = json!({"content": [{"type": "text", "text": "resolved"}]}); + assert_eq!( + heard, + [ + greeting(1), + json!({"jsonrpc": "2.0", "id": 2, "result": {"tools": tools}}), + refused(3, -32601, "MCP method is not admitted by this broker"), + greeting(4), + refused(5, -32600, "MCP tool call request is invalid"), + json!({"jsonrpc": "2.0", "id": "c", "result": said}), + ] + ); + let view = world.opened(&ended.run); + let digest = brokkr_core::canonical::sha256_bytes( + br#"{"result":{"content":[{"text":"resolved","type":"text"}]}}"#, + ); + assert_eq!( + (&view["response"], &view["call"]["state"]), + (&json!({"result": said}), &json!("succeeded")), + "{view}" + ); + assert_eq!( + world.calls(&ended.run), + [("resolve".into(), "succeeded".into(), Some(digest))] + ); +} diff --git a/openspec/changes/decision-0065-capabilities-slice-two/design.md b/openspec/changes/decision-0065-capabilities-slice-two/design.md index afa59e0b2..1bc7d38e2 100644 --- a/openspec/changes/decision-0065-capabilities-slice-two/design.md +++ b/openspec/changes/decision-0065-capabilities-slice-two/design.md @@ -1420,7 +1420,8 @@ witness/compose pins accompany only rows that change their inputs. | U1d6 | U1d5 | Record dsh's replacement on the keyed routes; 5.9–5.10 | `adapters/dsh.json`; `crates/brokkr-cli/src/init/adapters.rs` | | U9b1a | U1d3, U8e3, U8h, U8i, U8j, U8k, U1d4, U1d5, U1d6 | Lift the MCP compile fence where the namespace path is proved; no task (42.2, 46.1–46.2 are U9b1b's) | `crates/brokkr-runtime/src/capabilities.rs`; `crates/brokkr-runtime/src/capabilities/binding.rs`; `crates/brokkr-runtime/src/bundle/mcp.rs`; `crates/brokkr-runtime/src/engine/broker.rs`; `crates/brokkr-runtime/src/engine/broker/terminal.rs`; `crates/brokkr-runtime/src/engine/isolation.rs` | | U9b1b | U9b1a | Prove the enabled namespace path end to end; 42.2, 46.1–46.2 | `crates/brokkr-runtime/src/capabilities.rs`; `crates/brokkr-runtime/src/capabilities/binding.rs`; `crates/brokkr-runtime/src/bundle/mcp.rs`; `crates/brokkr-runtime/src/engine/broker.rs`; `crates/brokkr-runtime/src/engine/broker/terminal.rs`; `crates/brokkr-runtime/src/engine/isolation.rs`; `crates/brokkr-cli/src/verbs/setup.rs`; `crates/brokkr-cli/Cargo.toml` (F1, 2026-10-31) | -| U9b2 | U9b1b | Publish implemented scope and contract guidance; 47.1–47.2 | None | +| U9b1c | U9b1b | Negotiate the MCP protocol version and refuse unbrokerable grants at compile; 46.3–46.5 | `crates/brokkr-cli/src/broker/rpc/calls.rs`; `crates/brokkr-cli/src/broker/rpc.rs`; `crates/brokkr-runtime/src/bundle/mcp.rs`; `crates/brokkr-cli/tests/capability_broker/enabled/windows.rs` | +| U9b2 | U9b1b, U9b1c | Publish implemented scope and contract guidance; 47.1–47.2 | None | | U8m | U8h | Redact doctor's server words and refuse a storeless boxed writer; 47.3–47.4 | `crates/brokkr-cli/src/doctor/capabilities/mcp.rs`; `crates/brokkr-runtime/src/engine/boxed.rs` | | U10a | U9b2, U8m | Audit removals and run final candidate gates; 48.1–49.2 | None | | U10b | U10a | Fold the change and hand off; 49.3 | None | @@ -2492,6 +2493,25 @@ Controller rulings 2026-10-31 on U9b1b's first run (under the operator's standin - F5. The real broker and bootstrap never write invalid status tags or a malformed complete frame, so those rows of 42.2 and of MB4's complete-frame matrix are proved at the private seams. 42.2 and 46.2 accept those seams. - F6. A seat whose own writable reach covers its server's program is refused first by the lease's identity cause, not by MB3's "launch resolves inside seat-writable reach" entry. Both refuse with zero lookups. MB3's table in `specs/mcp-capability-broker/spec.md` records that order. +### U9b1c — Negotiate the MCP protocol version and refuse unbrokerable grants at compile + +Ruling F1 of 2026-10-31 asked the controller to run a real Claude and a real Codex harness through a brokered launch under `no_new_privs` before U9b1b landed. The check, on a scratch realm copied from U9b1b's `enabled` fixture, found two defects that the fixture's deterministic harness cannot see, because it speaks MCP 2025-06-18 and reports success whatever the broker says. + +(1) A real Claude seat holding the grant never reached its tools. Claude Code (2.1.3xx) opens with `initialize` asking protocol version 2025-11-25, and the broker refuses any version but its own (`crates/brokkr-cli/src/broker/rpc/calls.rs`, `Request::Initialize(id, _)` answering `Refused::Version`, -32602 "MCP protocol version is not supported by this broker", as MB3's text says). Claude Code then drops the server, and the seat runs without the tools it was granted. MCP 2025-06-18's lifecycle, which this design cites, says otherwise: "If the server supports the requested protocol version, it MUST respond with the same version. Otherwise, the server MUST respond with another protocol version it supports" (https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle). The broker now answers an `initialize` that asks any other version with its own 2025-06-18 result, and holds the session to 2025-06-18: a method that version does not admit still refuses "MCP method is not admitted by this broker", and every bound stays. An `initialize` whose `protocolVersion` is missing or not a string still refuses, with the existing cause. MB3's text in `specs/mcp-capability-broker/spec.md` says so. The controller's hand check: Claude Code asking 2025-11-25 accepted a stdio server that answered 2025-06-18, listed its tools and called one. + +(2) A Codex seat holding a brokered grant compiled on Linux, then parked at dispatch with "broker ledger is missing for this attempt; the driver then failed: refusing to invoke the agent CLI: provider 'codex' has no measured strict MCP configuration for 'brokers'". By design (U7d), Codex's fragment and dsh's home carry no broker, so the driver always refuses a brokered set for them. The compile now refuses such a site with the driver's own cause, where the fence is lifted, so it fails at compile rather than after a dispatch. A provider whose fragment carries the broker (Claude) is unchanged. + +(3) By the operator's ruling of 2026-10-31 ("B"), U9b1b landed by hand with task 46.1 open: its third review (M1) found the harness's death and the engine's timeout proved through real dispatch at every startup window but `Handoff`, which only the startup deadline ends. This unit proves both `Handoff` cases through real dispatch in `crates/brokkr-cli/tests/capability_broker/enabled/windows.rs`, establishing the delivered-frame, held-exec window (a store read alone does not establish it) and allowing for the tracer's delayed exec when bounding settlement: exact outcomes, teardown observations and a compiling removal each, then ticks 46.1. + +Files: `crates/brokkr-cli/src/broker/rpc/calls.rs` and `crates/brokkr-cli/src/broker/rpc.rs`; `crates/brokkr-runtime/src/bundle/mcp.rs`, or the existing private child that holds the compile's strict check; `specs/mcp-capability-broker/spec.md` (MB3's text); `crates/brokkr-cli/tests/capability_broker/enabled/windows.rs` (item 3). New private children as the standing child-module rule allows, and their tests. No public API rise, and no edit to `crates/brokkr-protocol/src/adapters.rs`, `adapters/*.json` or `adapters/mcp/admit.rs`. + +Closes tasks 46.3, 46.4 and 46.5, and 46.1's `Handoff` remainder; requirements [MB3](specs/mcp-capability-broker/spec.md), [SI2](specs/strict-mcp-isolation/spec.md), [MB5](specs/mcp-capability-broker/spec.md). +Proof: through the real binary, a harness asking 2025-11-25 is answered 2025-06-18, lists the granted tools and completes a call whose response is retained; one asking 2025-06-18 is unchanged; one that then calls a method 2025-06-18 does not admit is refused exactly; an `initialize` with no string version refuses as before. A Codex site and a dsh site holding a brokered grant refuse at compile with the driver's exact cause, and a Claude site still compiles. Independent compiling removals of the negotiation and of the compile refusal each fail their own test. At `Handoff`, the harness's death and the engine's timeout each end the attempt through real dispatch with their exact outcome, nothing left below the broker, and a compiling removal each. Host evidence, the controller's: a real Claude seat calls a granted tool through the broker and the call and its response are read back through `inspect --capability-call`. + +Controller ruling 2026-10-31 (under the operator's standing ruling of 2026-10-28, the recommended options of the controller's real-harness check findings R1 and R2): both close in this small unit after U9b1b and before U9b2, whose guides then describe a grant a real Claude seat can use; any other file comes back to the operator. The holds of 2026-10-30 stand until this unit lands; by the operator's ruling of 2026-10-31, the release hold also waits for the boxed dsh hands fix, its own unit after U9b1b. + +Owning tests: `crates/brokkr-cli/src/broker/rpc/tests.rs`, `crates/brokkr-cli/tests/capability_broker/enabled.rs` and its child modules, and the compile's MCP tests beside `crates/brokkr-runtime/src/bundle/mcp.rs`. + ### U9b2 — Publish implemented scope and contract guidance Publish grant/veto migration and measured namespace/stdio/empty-restriction limits, secret-read refusal, conservative installed-entry layout and protected source requirements, delayed settled checkpoints, fixed retention budgets, historical evidence protection and same-root run serialization. Explain the dedicated-installation credential/history limits, shared host-loopback/abstract-socket reach, certificate narrowing, measured source/startup budgets, separate hands/server networks, pre-secret box refusals versus post-lookup failures, private tmpfs, valid loading names only at confined exec, durable session failures, the broker's scalar/structural masking refusal and its fixed startup-key collisions; distinguish broker validation from the unchanged shared secret grammar and legacy mask_json semantics. diff --git a/openspec/changes/decision-0065-capabilities-slice-two/specs/mcp-capability-broker/spec.md b/openspec/changes/decision-0065-capabilities-slice-two/specs/mcp-capability-broker/spec.md index 2e61506dd..be9efe09c 100644 --- a/openspec/changes/decision-0065-capabilities-slice-two/specs/mcp-capability-broker/spec.md +++ b/openspec/changes/decision-0065-capabilities-slice-two/specs/mcp-capability-broker/spec.md @@ -365,8 +365,15 @@ SHALL not create another authority channel; unadmitted methods refuse "MCP method is not admitted by this broker". The admitted protocol is MCP 2025-06-18 over newline-delimited JSON-RPC 2.0. Initialize, initialized notification, ping, tools/list, tools/call and local cancellation handling are the only admitted operations; no server-initiated -request is forwarded to the harness. Unknown protocol versions refuse "MCP -protocol version is not supported by this broker". Bounds are 1 MiB per +request is forwarded to the harness. A harness initialize asking another +protocol version SHALL be answered with the broker's own 2025-06-18 result, as +MCP 2025-06-18's version negotiation requires +(https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle), +and the session SHALL stay at 2025-06-18: its admitted operations, refusals +and bounds are unchanged. An initialize whose protocolVersion is missing or +not a string refuses "MCP tool call request is invalid". A child server +answering initialize with another protocol version refuses "MCP protocol +version is not supported by this broker". Bounds are 1 MiB per request, 8 MiB per response/canonical artifact, JSON depth 64, 1,024 catalog tools over at most 32 pages, one forwarded call at a time per broker, 30 s initialize/list and 120 s per call (all also bounded by the attempt deadline). @@ -416,8 +423,9 @@ alone SHALL remain a call outcome, not a fatal session failure. #### Scenario: Protocol and resource bounds refuse exactly -- **WHEN** a caller selects another protocol version, exceeds the request/depth bound, or calls concurrently while one call is active -- **THEN** it receives respectively the unsupported-version, request-limit or active-call cause above, without forwarding that call +- **WHEN** a caller exceeds the request/depth bound, calls concurrently while one call is active, or initializes with no string protocol version +- **THEN** it receives respectively the request-limit, active-call or invalid-request cause above, without forwarding that call +- **AND** a caller asking another protocol version is answered with 2025-06-18, lists exactly the granted tools and completes a granted call, while a method 2025-06-18 does not admit still refuses "MCP method is not admitted by this broker"; a child server answering another version receives the unsupported-version cause - **AND** catalog overflow, more than 32 pages or a repeated cursor returns the catalog-limit cause; exact-limit positive controls still work - **AND** invalid tool vocabulary or parameter shape refuses "MCP tool call request is invalid" before acceptance; a valid bounded concurrent call has one recorded refused outcome and no child forwarding diff --git a/openspec/changes/decision-0065-capabilities-slice-two/tasks.md b/openspec/changes/decision-0065-capabilities-slice-two/tasks.md index eeb48a872..8ab1334bc 100644 --- a/openspec/changes/decision-0065-capabilities-slice-two/tasks.md +++ b/openspec/changes/decision-0065-capabilities-slice-two/tasks.md @@ -632,9 +632,17 @@ Dependencies: U9b1a for U9b1b, by operator ruling 2026-10-30; U1d3, U8h, U8i, U8 Status ([evidence/U9b1b.md](evidence/U9b1b.md)): open by operator ruling 2026-10-31 ("B"). Run 0065-slice-two-unit-u9b1b-see-th-9d7328d4's third review (M1) found the harness's death and the engine's timeout proved through real dispatch at every startup window but `Handoff`, which only the startup deadline ends. U9b1b lands by hand with this task open; U9b1c proves both `Handoff` cases through real dispatch, with their compiling removals, and ticks it. - [ ] 46.2 (U9b1b by operator ruling 2026-10-30; [GP1](specs/gate-capability-policy/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [MB2](specs/mcp-capability-broker/spec.md), [MB4](specs/mcp-capability-broker/spec.md), [CR2](specs/capability-response-retention/spec.md), [CR4](specs/capability-response-retention/spec.md), [SD3](specs/slice-two-delivery/spec.md), [SD2](specs/slice-two-delivery/spec.md), [SD4](specs/slice-two-delivery/spec.md), [MB5](specs/mcp-capability-broker/spec.md)) Through real compile, dispatch, broker, fold and inspect in the owning suites, repeat 46.1 and every boxed-server scenario-map row. Independently bind cross-worktree atomic admission, complete writer reach, reservation retention after engine death, receiver EOF, both private failure tags and initialization after status EOF; no earlier failing case proves a later cause. Use the success-reporting harness to pin failed-attempt causes and zero invented call rows, with healthy controls. Record all mandatory engine preparation and broker reobservation timings separately and together under SD4's budgets on both Linux profiles; deadline/cancellation proofs leave no detached admission worker. Audit current-head U0 qualification, unit removal evidence, real namespace results and macOS refusal before lifting the compile fence. Restore all mutations and run the shared verification duty; missing host evidence leaves this task pending. +## 46a. U9b1c — Negotiate the MCP protocol version and refuse unbrokerable grants at compile + +Dependencies: U9b1b. Files and scope: [U9b1c](design.md#u9b1c--negotiate-the-mcp-protocol-version-and-refuse-unbrokerable-grants-at-compile) (controller ruling 2026-10-31 under the operator's standing ruling of 2026-10-28). + +- [ ] 46.3 (U9b1c; [MB3](specs/mcp-capability-broker/spec.md), [SI2](specs/strict-mcp-isolation/spec.md)) Have the broker answer a harness's `initialize` that asks another protocol version with its own 2025-06-18 result, as MCP's version negotiation says, holding the session to 2025-06-18; refuse at compile a brokered grant on a provider whose fragment carries no broker, with the driver's own cause. Verify: a harness asking 2025-11-25 completes a granted call through the real broker; Codex and dsh sites holding a brokered grant refuse at compile exactly; a Claude site compiles. +- [ ] 46.4 (U9b1c; [MB3](specs/mcp-capability-broker/spec.md), [SD2](specs/slice-two-delivery/spec.md)) In the owning suites listed for this row, record independent exact assertions, compiling removal failures and restored passes for task 46.3. Apply the shared verification duty above. +- [ ] 46.5 (U9b1c by operator ruling 2026-10-31; [MB5](specs/mcp-capability-broker/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [SD2](specs/slice-two-delivery/spec.md)) Through real dispatch, end a broker's startup held at the `Handoff` window by the harness's death and by the engine's timeout of the attempt, each with its exact outcome, `inspect`'s view and nothing left below the broker, and record a compiling removal and restored pass for each; then tick 46.1, whose remainder this is. + ## 47. U9b2 — Publish implemented scope and contract guidance -Dependencies: U9b1b. Files and scope: [design.md](design.md#u9b2--publish-implemented-scope-and-contract-guidance) (operator ruling 2026-10-12). +Dependencies: U9b1b and U9b1c. Files and scope: [design.md](design.md#u9b2--publish-implemented-scope-and-contract-guidance) (operator ruling 2026-10-12). - [ ] 47.1 (U9b2; [SD3](specs/slice-two-delivery/spec.md), [SC1](specs/slice-two-contracts/spec.md), [SC2](specs/slice-two-contracts/spec.md), [SC3](specs/slice-two-contracts/spec.md), [SC4](specs/slice-two-contracts/spec.md), [MB2](specs/mcp-capability-broker/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [MB4](specs/mcp-capability-broker/spec.md), [MB5](specs/mcp-capability-broker/spec.md)) Publish grant/veto migration and measured namespace/stdio/empty-restriction limits, secret-read refusal, conservative installed-entry layout and protected source requirements, delayed settled checkpoints, fixed retention budgets, historical evidence protection and same-root run serialization. Explain the dedicated-installation credential/history limits, shared host-loopback/abstract-socket reach, certificate narrowing, measured source/startup budgets, separate hands/server networks, pre-secret box refusals versus post-lookup failures, private tmpfs, allowed loading names only at confined exec, durable session failures, the broker's scalar/structural masking refusal and its fixed startup-key collisions; distinguish broker validation from the unchanged shared secret grammar and legacy mask_json semantics. Verify: Guides agree with actual compile/report/inspect evidence and explain quiescing old writers; no new realm grant, MCP server or unmeasured support claim is shipped. Explain source reservations across worktrees, refusal of unknown coordination or unsettled survivors, and migration of overlapping older writers; distinguish this from artifact-root serialization. - [ ] 47.2 (U9b2; [SD3](specs/slice-two-delivery/spec.md), [SC1](specs/slice-two-contracts/spec.md), [SC2](specs/slice-two-contracts/spec.md), [SC3](specs/slice-two-contracts/spec.md), [SC4](specs/slice-two-contracts/spec.md), [SD2](specs/slice-two-delivery/spec.md), [SD4](specs/slice-two-delivery/spec.md), [MB2](specs/mcp-capability-broker/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [MB4](specs/mcp-capability-broker/spec.md), [MB5](specs/mcp-capability-broker/spec.md)) Review the listed documents/evidence against the named requirements and retain explicit unmeasured/pending outcomes. Apply the shared verification duty above. diff --git a/quality/file-lines.txt b/quality/file-lines.txt index 342afc1fa..34dd12e46 100644 --- a/quality/file-lines.txt +++ b/quality/file-lines.txt @@ -9,10 +9,10 @@ 657 crates/brokkr-cli/src/broker/bootstrap.rs 105 crates/brokkr-cli/src/broker/bootstrap/handshake.rs 399 crates/brokkr-cli/src/broker/output.rs - 786 crates/brokkr-cli/src/broker/rpc.rs - 693 crates/brokkr-cli/src/broker/rpc/calls.rs + 784 crates/brokkr-cli/src/broker/rpc.rs + 695 crates/brokkr-cli/src/broker/rpc/calls.rs 85 crates/brokkr-cli/src/broker/rpc/calls/outbox.rs - 139 crates/brokkr-cli/src/broker/rpc/calls/request.rs + 140 crates/brokkr-cli/src/broker/rpc/calls/request.rs 216 crates/brokkr-cli/src/broker/rpc/pieces.rs 777 crates/brokkr-cli/src/broker/session.rs 314 crates/brokkr-cli/src/broker/session/lifetime.rs @@ -375,7 +375,7 @@ 368 crates/brokkr-cli/src/boundary/tests.rs 872 crates/brokkr-cli/src/broker/bootstrap/tests.rs 406 crates/brokkr-cli/src/broker/rpc/pieces/tests.rs - 1986 crates/brokkr-cli/src/broker/rpc/tests.rs + 1995 crates/brokkr-cli/src/broker/rpc/tests.rs 658 crates/brokkr-cli/src/broker/rpc/tests/delivery.rs 1508 crates/brokkr-cli/src/broker/rpc/tests/masking.rs 403 crates/brokkr-cli/src/broker/rpc/tests/retention.rs @@ -457,11 +457,12 @@ 504 crates/brokkr-cli/tests/capability_artifacts.rs 2000 crates/brokkr-cli/tests/capability_broker.rs 462 crates/brokkr-cli/tests/capability_broker/cleanup.rs - 689 crates/brokkr-cli/tests/capability_broker/enabled.rs + 691 crates/brokkr-cli/tests/capability_broker/enabled.rs 342 crates/brokkr-cli/tests/capability_broker/enabled/boxes.rs 241 crates/brokkr-cli/tests/capability_broker/enabled/dispatch.rs 269 crates/brokkr-cli/tests/capability_broker/enabled/lifetime.rs 440 crates/brokkr-cli/tests/capability_broker/enabled/matrix.rs + 82 crates/brokkr-cli/tests/capability_broker/enabled/negotiation.rs 235 crates/brokkr-cli/tests/capability_broker/enabled/reach.rs 409 crates/brokkr-cli/tests/capability_broker/enabled/sites.rs 201 crates/brokkr-cli/tests/capability_broker/enabled/transport.rs From 695bb5dda94907c416d1f6e15eb5bf28945b35dc Mon Sep 17 00:00:00 2001 From: Valentin Yanakiev Date: Sun, 1 Nov 2026 04:31:19 +0200 Subject: [PATCH 2/3] capabilities: a brokered grant on a harness whose fragment carries no broker refuses at compile with the driver's own cause (decision 0065 slice two, U9b1c) A Codex seat holding a brokered grant compiled on Linux and then parked at dispatch, the driver refusing "provider 'codex' has no measured strict MCP configuration for 'brokers'" (the controller's real-harness check, R2). By U7d's design Codex's fragment and dsh's engine-only home carry no broker, so the driver always refuses such a set for them. Past the lifted fence, the compile now refuses that site in its own words with the driver's StrictCause, named by AdapterKind and matched exhaustively, before its SI2 verdict, as the driver orders them. A Claude site compiles holding the grant; a site holding no MCP is unchanged; while the fence stands, as on macOS, nothing moves. The compile's MCP tests gain one case per harness through the real pass, and the pass test's shape and resolver setup becomes shared builders. Co-Authored-By: Claude Opus 5.5 --- .../src/bundle/agent_tests/mcp_tests.rs | 172 ++++++++++++++---- crates/brokkr-runtime/src/bundle/mcp.rs | 24 ++- quality/file-lines.txt | 4 +- 3 files changed, 156 insertions(+), 44 deletions(-) diff --git a/crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs b/crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs index 0ae635618..2f5353a58 100644 --- a/crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs +++ b/crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs @@ -1500,59 +1500,85 @@ fn only_an_mcp_holding_contributes_and_a_set_without_one_is_itself() { ); } -/// The capability pass judges each candidate's contribution once its own -/// outcome is resolved (U7b): with the resolver's outcomes sealing an MCP -/// holding at both links, the carrying primary keeps its holding while the -/// Codex fallback, whose carriage is measured unsupported, drops a `wants` -/// with MB2's notice and refuses a `requires` in MB2's exact site-prefixed -/// words. The resolver is the compile's own, its outcomes then holding -/// `library-docs` as no compile can until U9b. -#[test] -fn the_pass_judges_each_resolved_candidate_and_refuses_in_the_sites_words() { - use crate::capabilities::{parse_requests, Authority, CapabilityContext, Outcome, SiteAsks}; - let fixture = AgentFixture::new(); - let carriage = json!({"unsupported": "engine MCP loading is unavailable"}); - // MB2's hard checks come first and hold here: each boxed shape is proved. - let proved = |harness: &str, invocations: &[Value]| { - let fact = json!({"measured": "synthetic fixture"}); - let shape = |invocation: &Value| { - let mut shape = synthetic_shape(harness, invocation.clone(), "boxed", fact.clone()); - for axis in ["native_write", "store_read", "process_read"] { - shape[axis] = fact.clone(); - } - shape - }; - invocations.iter().map(shape).collect::>() +/// `harness`'s boxed `invocations`, each proved on every axis: MB2's hard +/// checks come first, and each holds on these. +fn proved_boxed(harness: &str, invocations: &[Value]) -> Vec { + let fact = json!({"measured": "synthetic fixture"}); + let shape = |invocation: &Value| { + let mut shape = synthetic_shape(harness, invocation.clone(), "boxed", fact.clone()); + for axis in ["native_write", "store_read", "process_read"] { + shape[axis] = fact.clone(); + } + shape }; + invocations.iter().map(shape).collect() +} + +/// [`carriers`] with the Codex fallback's `carriage` beside its boxed cold +/// and replacement shapes, and the strict Claude primary's boxed shapes, +/// its boxed rejoin included, each proved ([`proved_boxed`]). +fn proved_carriers(fixture: &AgentFixture, carriage: Value) { let (cold, replacement) = (json!("cold"), json!("replacement")); - let shapes = proved("codex", &[cold.clone(), replacement.clone()]); - carriers(&fixture, json!({"carriage": carriage, "shapes": shapes})); + let shapes = proved_boxed("codex", &[cold.clone(), replacement.clone()]); + carriers(fixture, json!({"carriage": carriage, "shapes": shapes})); let rejoin = json!({"resume": "boxed-rejoin"}); - let shapes = proved("claude", &[cold, replacement, rejoin]); + let shapes = proved_boxed("claude", &[cold, replacement, rejoin]); fixture.write("adapters/claude.json", strict_claude(shapes)); +} + +/// `agent`'s `work` site, compiled under the namespace boundary, through the +/// capability pass by the compile's own resolver, under an authority loaded +/// with the fence `lifted` or standing. Where the site asks `strength` of +/// `library-docs`, every candidate's outcome then holds it through +/// `library-docs-mcp`, as no compile can until U9b; otherwise it asks and +/// holds nothing. +fn holding_docs( + fixture: &AgentFixture, + agent: &str, + strength: Option<&str>, + lifted: bool, +) -> Result { + use crate::capabilities::{parse_requests, Authority, CapabilityContext, Outcome, SiteAsks}; let mut config = fixture.config(); - config["seats"]["work"]["agent"] = json!("boxed"); + config["seats"]["work"]["agent"] = json!(agent); let bundle = fixture.compile_under(config, Boundary::Namespace).unwrap(); - let chain = &bundle.sites["work"].chain; let adapters = Adapters::load(&fixture.adapters()).unwrap(); let context = CapabilityContext::no_grants("private", &fixture.root); + let lift = lifted.then(McpFence::lift); let authority = Authority::load(context).unwrap(); + drop(lift); let unasked = SiteAsks::at(SeatClass::Work, "research", None, None).unwrap(); let resolve = |_: &SiteAsks, serving: &crate::capabilities::Serving<'_>| { let mut outcome: Outcome = mcp::resolve(&authority, &unasked, serving)?; - let holding = mcp_holding("library-docs"); - outcome.held.insert("library-docs".to_string(), holding); + if strength.is_some() { + let holding = mcp_holding("library-docs"); + outcome.held.insert("library-docs".to_string(), holding); + } Ok(outcome) }; - let pass = |strength: &str| { - let requests = parse_requests("seat 'research'", &json!({"library-docs": strength})); - let requests = requests.unwrap(); - let office = Some(("researcher", &requests)); - let asks = SiteAsks::at(SeatClass::Work, "research", office, None).unwrap(); - let loaded = CapabilityAdapters::loaded(&adapters); - let planned = (chain.as_slice(), None); - mcp::candidate_capabilities_with(&authority, loaded, asks, planned, &resolve).capabilities - }; + let asked = strength.map_or(json!({}), |strength| json!({"library-docs": strength})); + let requests = parse_requests("seat 'research'", &asked).unwrap(); + let office = Some(("researcher", &requests)); + let asks = SiteAsks::at(SeatClass::Work, "research", office, None).unwrap(); + let loaded = CapabilityAdapters::loaded(&adapters); + let planned = (bundle.sites["work"].chain.as_slice(), None); + mcp::candidate_capabilities_with(&authority, loaded, asks, planned, &resolve).capabilities +} + +/// The capability pass judges each candidate's contribution once its own +/// outcome is resolved (U7b): with the resolver's outcomes sealing an MCP +/// holding at both links, the carrying primary keeps its holding while the +/// Codex fallback, whose carriage is measured unsupported, drops a `wants` +/// with MB2's notice and refuses a `requires` in MB2's exact site-prefixed +/// words. The resolver is the compile's own, its outcomes then holding +/// `library-docs` as no compile can until U9b. +#[test] +fn the_pass_judges_each_resolved_candidate_and_refuses_in_the_sites_words() { + use crate::capabilities::Outcome; + let fixture = AgentFixture::new(); + let carriage = json!({"unsupported": "engine MCP loading is unavailable"}); + proved_carriers(&fixture, carriage); + let pass = |strength: &str| holding_docs(&fixture, "boxed", Some(strength), false); let site = pass("wants").unwrap(); let cause = "provider 'codex' cannot carry MCP (engine MCP loading is unavailable)"; let notice = format!( @@ -1583,6 +1609,74 @@ fn the_pass_judges_each_resolved_candidate_and_refuses_in_the_sites_words() { ); } +/// A brokered grant refuses at compile where its harness carries no broker +/// (U9b1c; SI2): past the lifted fence, a Codex site and a dsh site holding +/// `library-docs` refuse in the site's words with the driver's own cause, +/// which every other check here passes, while a Claude site compiles holding +/// it. A Codex or dsh site holding no MCP compiles past the fence as before, +/// and while the fence stands, as on macOS, every site compiles as before. +#[test] +fn a_brokered_grant_on_a_harness_carrying_no_broker_refuses_at_compile_with_the_drivers_cause() { + let fixture = AgentFixture::new(); + proved_carriers(&fixture, json!({"measured": "synthetic carriage"})); + let mut dsh: Value = serde_json::from_slice( + &std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../adapters/dsh.json")) + .unwrap(), + ) + .unwrap(); + let (cold, replacement) = (json!("cold"), json!("replacement")); + let shapes = proved_boxed("dsh", &[cold, replacement]); + dsh["mcp"] = json!({"carriage": {"measured": "synthetic carriage"}, "shapes": shapes}); + fixture.write("adapters/dsh.json", dsh); + let offices = [ + ("on-claude", "opus"), + ("on-codex", "astra"), + ("on-dsh", "flash"), + ]; + for (office, model) in offices { + fixture.write( + &format!("agents/{office}.json"), + boxed_agent(&[model], json!({})), + ); + } + let judged = |strength: Option<&str>, lifted: bool| { + offices.map( + |(office, _)| match holding_docs(&fixture, office, strength, lifted) { + Ok(site) => Ok(site + .outcomes + .iter() + .flat_map(|o| o.held.keys().cloned()) + .collect()), + Err(CompileError::Capability(refusal)) => Err(refusal), + Err(other) => panic!("a compile refusal here is a capability one: {other:?}"), + }, + ) + }; + let brokers = |provider: &str| { + Err(format!( + "{RESEARCH}: provider '{provider}' has no measured strict MCP configuration for \ + 'brokers'" + )) + }; + let held: Result, String> = Ok(vec!["library-docs".to_string()]); + assert_eq!( + judged(Some("requires"), true), + [held.clone(), brokers("codex"), brokers("dsh")] + ); + assert_eq!( + judged(Some("wants"), true)[1..], + [brokers("codex"), brokers("dsh")] + ); + assert_eq!( + judged(None, true), + [Ok(Vec::new()), Ok(Vec::new()), Ok(Vec::new())] + ); + assert_eq!( + judged(Some("requires"), false), + [held.clone(), held.clone(), held] + ); +} + /// `adapter` declaring that its harness authenticates with `name` (MB4). fn authenticating(mut adapter: Value, name: &str) -> Value { adapter["harness_auth"] = json!({"names": [name], "measured": "a fixture"}); diff --git a/crates/brokkr-runtime/src/bundle/mcp.rs b/crates/brokkr-runtime/src/bundle/mcp.rs index 49ac32a0f..7416ae820 100644 --- a/crates/brokkr-runtime/src/bundle/mcp.rs +++ b/crates/brokkr-runtime/src/bundle/mcp.rs @@ -740,10 +740,25 @@ fn resolved( } } +/// The driver's own cause for the brokered `set` where its `harness` carries +/// no broker (U7d, U9b1c; SI2): Claude's fragment carries them, while Codex's +/// fragment and dsh's engine-only home do not, so the driver refuses such a +/// set for either whichever way the fence stands. Any other set meets none. +fn unbrokered(set: McpIntent, harness: &str) -> Option { + let carried = match AdapterKind::parse(harness) { + Some(AdapterKind::Codex | AdapterKind::Dsh) => false, + Some(AdapterKind::Claude | AdapterKind::Lanetally | AdapterKind::Exec) | None => true, + }; + let (provider, shape) = (harness.to_string(), "brokers".to_string()); + (set == McpIntent::Brokered && !carried).then_some(StrictCause::Unmeasured { provider, shape }) +} + /// `asks` resolved by `resolve` against each serving in order, each outcome -/// then given its own MCP contribution by its `sites` entry and judged by -/// that serving's SI2 verdict in `strict`, or the first refusal; the -/// records are kept beside the outcomes, which way the fence stands. +/// then given its own MCP contribution by its `sites` entry, refused past +/// the fence where its brokered set rides a harness carrying no broker +/// ([`unbrokered`]), and judged by that serving's SI2 verdict in `strict`, +/// or the first refusal; the records are kept beside the outcomes, which +/// way the fence stands. fn sealed( authority: &Authority, (asks, resolve): (SiteAsks, Resolve<'_>), @@ -758,6 +773,9 @@ fn sealed( for ((serving, site), verdict) in servings.iter().zip(sites).zip(strict) { let mut outcome = resolve(&asks, serving)?; (site.contribute(who, &asks.asks, &mut outcome)).map_err(|cause| refused(&cause))?; + let set = site.intent.map(|intent| intent.brokering(Some(&outcome))); + let brokers = set.and_then(|set| unbrokered(set, serving.harness)); + (authority.fence.admit(brokers)).map_err(|cause| refused(&cause))?; outcomes.push(outcome); (authority.fence.admit(verdict.clone())).map_err(|cause| refused(&cause))?; records.push(site.strict); diff --git a/quality/file-lines.txt b/quality/file-lines.txt index 34dd12e46..60d3da06b 100644 --- a/quality/file-lines.txt +++ b/quality/file-lines.txt @@ -248,7 +248,7 @@ 73 crates/brokkr-runtime/src/bundle/expand.rs 102 crates/brokkr-runtime/src/bundle/gate.rs 134 crates/brokkr-runtime/src/bundle/invocation.rs - 770 crates/brokkr-runtime/src/bundle/mcp.rs + 788 crates/brokkr-runtime/src/bundle/mcp.rs 454 crates/brokkr-runtime/src/bundle/model_policy.rs 332 crates/brokkr-runtime/src/bundle/opened.rs 47 crates/brokkr-runtime/src/bundle/origins.rs @@ -658,7 +658,7 @@ 407 crates/brokkr-runtime/src/bundle/agent_tests/charter_tests.rs 588 crates/brokkr-runtime/src/bundle/agent_tests/gate_tests.rs 44 crates/brokkr-runtime/src/bundle/agent_tests/hire_order_tests.rs - 1677 crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs + 1771 crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs 324 crates/brokkr-runtime/src/bundle/agent_tests/opened_tests.rs 28 crates/brokkr-runtime/src/bundle/agent_tests/sandbox_tests.rs 14 crates/brokkr-runtime/src/bundle/aggregate/tests.rs From 30734c2e60368a8788a7809d39b1021b07e1a6ec Mon Sep 17 00:00:00 2001 From: Valentin Yanakiev Date: Sun, 1 Nov 2026 04:50:11 +0200 Subject: [PATCH 3/3] capabilities: the harness's death and the engine's timeout each end a broker held at Handoff through real dispatch (decision 0065 slice two, U9b1c) U9b1b landed with 46.1 open: its windows ended every startup window three ways but Handoff, which only the startup deadline ended (third review, M1; operator ruling 2026-10-31, "B"). windows.rs now holds Handoff by what establishes it, not by a store read: the bootstrap stopped by the tracer inside its execve of the server, which it enters only once it has the whole binding frame and its end, holding past its standard streams exactly one descriptor, a pipe the broker holds too, its status pipe. At that window the harness's death ends the broker before its deadline, with inspect keeping the seat's result unrecorded and no call row, and the engine's timeout parks the run on its own words. Nothing below the broker remains either way. The deadline-bound check now runs for every window where the startup deadline still stands, because TEARDOWN (30 s) is the whole startup and no longer tells a cancellation from the deadline. Settlement after the harness's death allows for the tracer's delayed exec, which ends a process killed in it only when the delay ends. The module text again claims every window. 46.1 and 46.3-46.5 are ticked; evidence/U9b1c.md records each exact assertion, compiling removal and restored pass, the gates and what stays pending (exact coverage, macOS CI, the controller's real Claude seat), and evidence/U9b1b.md's Tasks paragraph says 46.1 closed here. Co-Authored-By: Claude Opus 5.5 --- .../capability_broker/enabled/windows.rs | 157 +++++++++++++++--- .../evidence/U9b1b.md | 3 +- .../evidence/U9b1c.md | 143 ++++++++++++++++ .../tasks.md | 11 +- quality/file-lines.txt | 2 +- 5 files changed, 284 insertions(+), 32 deletions(-) create mode 100644 openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1c.md diff --git a/crates/brokkr-cli/tests/capability_broker/enabled/windows.rs b/crates/brokkr-cli/tests/capability_broker/enabled/windows.rs index 40de7db40..73e3d613c 100644 --- a/crates/brokkr-cli/tests/capability_broker/enabled/windows.rs +++ b/crates/brokkr-cli/tests/capability_broker/enabled/windows.rs @@ -1,10 +1,11 @@ //! Task 46.1's bounded startup and its cancellation, through real dispatch -//! (U9b1b; MB3, MB5): each window of a broker's startup but `Handoff` held, then -//! ended three ways; `Handoff` only by the deadline, the other two are U9b1c's. +//! (U9b1b, and U9b1c's 46.5 at `Handoff`; MB3, MB5): each window of a broker's +//! startup held, then ended three ways. //! //! The harness's death, the broker's own sign of cancellation, its input kept //! open past it by a process of another session, so EOF on that input is not -//! what ends it. The broker ends within seconds. What it left is bounded, not +//! what ends it. The broker ends within seconds, and wherever the startup +//! deadline still stands, before that deadline would. What it left is bounded, not //! exact: its ledger is a prefix of the window's records, closed on the window's //! disposition only where it closed at all, as the engine's sweep may kill it //! first; and the run parks on the window's cause or on a driver's lost result, @@ -19,7 +20,9 @@ //! //! Every way, nothing that stood below the broker remains, its private mounts //! gone with the last process of their namespaces, and pre-secret windows keep -//! the store unread. +//! the store unread. A process the tracer holds in its delayed exec at `Handoff` +//! ends only with the delay, which bounds its settlement wherever the tracer +//! outlives the broker. use std::path::PathBuf; use std::process::{Child, Stdio}; @@ -53,6 +56,9 @@ const PATIENCE: Duration = Duration::from_secs(90); /// once its end is due: seconds, on a hosted runner as on a quiet host. const TEARDOWN: Duration = Duration::from_secs(30); +/// MB3's startup deadline, which no window here moves. +const STARTUP: Duration = Duration::from_secs(30); + /// A window of a broker's startup, held open. #[derive(Debug, Clone, Copy, PartialEq)] enum Window { @@ -137,6 +143,28 @@ impl Window { } } + /// How much longer than [`TEARDOWN`] what stood below the broker may take + /// to settle: at `Handoff` the tracer delays the exec at its entry, and a + /// process killed in that delay ends only with it, wherever the tracer + /// outlives the broker; elsewhere no delay holds a process. + fn delayed(self) -> Duration { + match self { + Window::Handoff => Duration::from_millis(u64::from(HELD_MS)), + Window::Observation + | Window::Entry + | Window::StoreRead + | Window::Initialization + | Window::Call => Duration::ZERO, + } + } + + /// Whether MB3's startup deadline still stands in this window, so that only + /// a broker ended before it was ended by something else: every window but + /// a call's, which its own deadline bounds. + fn starting(self) -> bool { + self != Window::Call + } + /// Whether a server's box stands in this window. fn boxed(self) -> bool { self != Window::Observation @@ -191,6 +219,48 @@ fn mount_space(pid: i32) -> Option { std::fs::read_link(format!("/proc/{pid}/ns/mnt")).ok() } +/// The number Linux gives `execve` on this architecture, as +/// `/proc//syscall` names a process's current call. +#[cfg(target_arch = "x86_64")] +const EXECVE: &str = "59"; +#[cfg(not(target_arch = "x86_64"))] +const EXECVE: &str = "221"; + +/// What `pid`'s descriptors from `from` up refer to. +fn descriptors(pid: i32, from: u32) -> Vec { + let Ok(open) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { + return Vec::new(); + }; + let open = open.flatten().filter(|entry| { + let number = entry + .file_name() + .to_str() + .and_then(|name| name.parse().ok()); + number.is_some_and(|number: u32| number >= from) + }); + open.filter_map(|entry| std::fs::read_link(entry.path()).ok()) + .collect() +} + +/// Whether `Handoff` is held below `broker`: the bootstrap stopped by the +/// tracer inside its `execve` of the server, which it enters only once it has +/// received the binding frame whole and then its end, its control and ready +/// pipes given up; and holding past its standard streams exactly one +/// descriptor, a pipe the broker holds too, its status pipe, open. +fn exec_held(broker: i32) -> bool { + let theirs = descriptors(broker, 0); + below(broker).into_iter().any(|pid| { + let call = std::fs::read_to_string(format!("/proc/{pid}/syscall")).unwrap_or_default(); + let traced = status(pid).is_some_and(|(state, _, _)| state == 't'); + let open = descriptors(pid, 3); + let piped = open + .iter() + .all(|held| held.to_string_lossy().starts_with("pipe:")); + let status_pipe = open.len() == 1 && piped && theirs.contains(&open[0]); + traced && call.split(' ').next() == Some(EXECVE) && status_pipe + }) +} + /// Waits until `window` is held below `broker`. fn held(world: &World, window: Window, broker: i32, reads: &mut dyn FnMut() -> usize) { let until = Instant::now() + PATIENCE; @@ -211,7 +281,12 @@ fn held(world: &World, window: Window, broker: i32, reads: &mut dyn FnMut() -> u std::thread::sleep(Duration::from_millis(200)); first && stopped() } - Window::StoreRead | Window::Handoff => reads() > 0, + Window::StoreRead => reads() > 0, + Window::Handoff => { + let first = reads() > 0 && exec_held(broker); + std::thread::sleep(Duration::from_millis(200)); + first && exec_held(broker) + } Window::Initialization => holding(), Window::Call => { let config = world.bin("config.json"); @@ -307,14 +382,23 @@ impl Standing { }) } - /// Waits for the broker's end, within `bound`. - fn broker_ended(&self, bound: Duration) { + /// Waits for the broker's end, within `bound`: when it was seen ended. + fn broker_ended(&self, bound: Duration) -> SystemTime { let begun = Instant::now(); while status(self.broker).is_some_and(|(state, _, _)| state != 'Z') { let taken = begun.elapsed(); assert!(taken < bound, "{:?}: {taken:?}", self.window); std::thread::sleep(Duration::from_millis(10)); } + SystemTime::now() + } + + /// How long after the harness started, and its broker with it, `at` came. + fn lasted(&self, at: SystemTime) -> Duration { + let times = std::fs::read_to_string(self.world.bin("times")).unwrap(); + let begun: u64 = times.lines().next().unwrap().parse().unwrap(); + let harness = SystemTime::UNIX_EPOCH + Duration::from_nanos(begun); + at.duration_since(harness).unwrap() } /// Nothing of the window remains within `bound`: its private mounts, gone @@ -343,8 +427,8 @@ impl Standing { } /// Whether the store was read, and the server's program, however it ended; - /// each window's own, held to its table. - fn reads_held(self) { + /// each window's own, held to its table; and the world, to look at again. + fn reads_held(self) -> World { let window = self.window; let (_, read) = window.expected(); let reads = self.seen + self.lookups.lookups(); @@ -360,6 +444,7 @@ impl Standing { 1, "{window:?}: the harness started once" ); + self.world } /// The broker's ledger, its records' kinds and its last disposition. @@ -391,18 +476,24 @@ impl Standing { } } -/// The proof of a harness's death at `window`: see the module's text. -fn cancelled_at(window: Window) { - let Some(mut standing) = Standing::at(window, |world| { +/// The proof of a harness's death at `window`: see the module's text. Its +/// world and how the run ended, for a window's own proof beside it. +fn cancelled_at(window: Window) -> Option<(World, Ended)> { + let mut standing = Standing::at(window, |world| { put(&world.bin("keep-input"), ""); - }) else { - return; - }; + })?; killed(standing.harness); let keeper = pid_in(&standing.world, "keeper.pid"); - standing.broker_ended(TEARDOWN); + let gone = standing.broker_ended(TEARDOWN); + // TEARDOWN is MB3's whole startup, so only the broker's lifetime tells the + // harness's death from the deadline it would otherwise have met. + let lasted = standing.lasted(gone); + assert!( + !window.starting() || lasted < STARTUP, + "{window:?}: the broker lasted {lasted:?}" + ); killed(keeper); - standing.settled(TEARDOWN); + standing.settled(TEARDOWN + window.delayed()); let ended = standing.engine_ended(); // The broker's own record, closed on the window's cause, or a prefix of it // where the engine's sweep of the attempt's tree killed it before it closed. @@ -424,7 +515,7 @@ fn cancelled_at(window: Window) { ended.status == "awaiting_operator" && (parked == cause || parked.ends_with(lost)), "{window:?} {ended:?}" ); - standing.reads_held(); + Some((standing.reads_held(), ended)) } #[test] @@ -442,6 +533,21 @@ fn a_harness_dying_at_a_held_store_read_ends_its_broker() { cancelled_at(Window::StoreRead); } +/// At `Handoff`, the window [`exec_held`] establishes, the harness's death +/// ends the broker before its startup deadline could, and `inspect` keeps the +/// seat's report of success from standing, with no call of the attempt. +#[test] +fn a_harness_dying_at_a_held_handoff_ends_its_broker_before_its_deadline() { + let Some((world, ended)) = cancelled_at(Window::Handoff) else { + return; + }; + let seat = world.inspected(&ended.run)["dashboard"]["last_seat"].clone(); + assert_eq!( + (&seat["seat"], &seat["result"], world.calls(&ended.run)), + (&json!("work"), &Value::Null, vec![]) + ); +} + #[test] fn a_harness_dying_during_initialization_ends_its_broker() { cancelled_at(Window::Initialization); @@ -499,6 +605,11 @@ fn the_engines_timeout_at_a_held_store_read_ends_it() { timed_out_at(Window::StoreRead, &[]); } +#[test] +fn the_engines_timeout_at_a_held_handoff_ends_it() { + timed_out_at(Window::Handoff, &[]); +} + #[test] fn the_engines_timeout_during_initialization_ends_it() { timed_out_at(Window::Initialization, &[]); @@ -523,8 +634,7 @@ fn expired_at(window: Window) { standing.world.unserialized(); // The broker's end by its own state: a process held in the tracer's delay is // reaped only with the delay's end, which is the hold's and not the deadline's. - standing.broker_ended(PATIENCE); - let at = SystemTime::now(); + let at = standing.broker_ended(PATIENCE); let ended = standing.engine_ended(); let (cause, _) = window.expected(); assert_eq!( @@ -534,13 +644,10 @@ fn expired_at(window: Window) { ); let world = &standing.world; assert_eq!(world.broker_code(), Some(1), "{window:?}"); - let times = std::fs::read_to_string(world.bin("times")).unwrap(); - let begun: u64 = times.lines().next().unwrap().parse().unwrap(); - let harness = SystemTime::UNIX_EPOCH + Duration::from_nanos(begun); - let lasted = at.duration_since(harness).unwrap(); + let lasted = standing.lasted(at); let released = Duration::from_millis(u64::from(HELD_MS)); assert!( - lasted >= Duration::from_secs(30) && lasted < released, + lasted >= STARTUP && lasted < released, "{window:?}: {lasted:?}" ); assert_eq!(world.calls(&ended.run), [], "{window:?}"); diff --git a/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1b.md b/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1b.md index 663542f3e..0437946e5 100644 --- a/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1b.md +++ b/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1b.md @@ -368,7 +368,8 @@ was ticked before the first review on a test that began no call; it now meets it the engine's timeout proved through real dispatch at every window but `Handoff`, which only the startup deadline ends, so the claim above that each window ends three ways stops short of `Handoff`. By operator ruling 2026-10-31 ("B") U9b1b lands by hand with 46.1 open, and U9b1c -proves both `Handoff` cases, with their compiling removals, and ticks it. 46.2 stays unticked: its non-host work is +proves both `Handoff` cases, with their compiling removals, and ticks it. 46.1 closed there, in +U9b1c's 46.5 ([U9b1c.md](U9b1c.md)). 46.2 stays unticked: its non-host work is done above, audit included, and by its own last sentence "missing host evidence leaves this task pending". diff --git a/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1c.md b/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1c.md new file mode 100644 index 000000000..ccaad193e --- /dev/null +++ b/openspec/changes/decision-0065-capabilities-slice-two/evidence/U9b1c.md @@ -0,0 +1,143 @@ +# U9b1c evidence — MCP version negotiation, the compile's broker refusal and `Handoff` + +Run 0065-slice-two-unit-u9b1c-see-th-60e69132, branch `s2/U9b1c` from main at +`c21ab42d9`. One signed commit per item: `42d54bb42` (negotiation, with the commission's +design and tasks), `695bb5dda` (the compile's refusal) and the branch head (`Handoff`), +the tasks and this file folded into the third. The tests ran on the third commit's code, +before the tasks and this file were folded in. +Every claim below names a command the implement seat ran on it, or is marked pending. + +The protocol source relied on is MCP 2025-06-18's lifecycle, +, read in the +seat. Its Version Negotiation says: "If the server supports the requested protocol +version, it MUST respond with the same version. Otherwise, the server MUST respond with +another protocol version it supports." Its Operation section says both parties "MUST +respect the negotiated protocol version". The controller's record of the real-harness +check is `.forge/u9b1c/check-2026-10-31.md`, read but not cited by any test. + +## Item 1 — negotiation (46.3, 46.4; MB3) + +`calls.rs` answers every `Request::Initialize` with `greet`, MB3's 2025-06-18 result. +`calls/request.rs` (an existing private child of `calls.rs`) still decodes +`protocolVersion` as a `String`, so a missing or non-string version keeps +`Refused::Invalid` ("MCP tool call request is invalid", -32600). The harness-facing +`Refused::Version` had no other consumer and is removed (ruling 6). `Fatal::Unsupported` +now writes its own text, unchanged, for a child server off 2025-06-18. The session's +admitted methods and bounds are untouched, so the session stays at 2025-06-18. MB3's +text in `specs/mcp-capability-broker/spec.md` says so, and its "Protocol and resource +bounds" scenario is rewritten to match. + +| Test | Observed | Compiling removal and failure | +|---|---|---| +| `broker::rpc::tests::the_harness_is_answered_here_and_its_granted_call_forwarded_once_recorded` | Request 3 asking 2025-11-25 is answered with the exact 2025-06-18 greeting; request 6 with `protocolVersion: 7` refuses -32600 "MCP tool call request is invalid"; ids 4 (no params) and 5 (an array) as before; the call forwarded once | `request.rs` mapping a non-2025-06-18 greeting to `Request::Other`: `left` holds `-32601 "MCP method is not admitted by this broker"` for id 3 where `right` holds the greeting (tests.rs:1020) | +| `capability_broker::enabled::negotiation::a_harness_asking_another_version_is_answered_mb3s_and_completes_a_granted_call` (new child `enabled/negotiation.rs`) | Through `brokkr run`, the real broker and server box: the harness heard exactly the 2025-06-18 greeting for its 2025-11-25 `initialize`, the listing `resolve` and `read`, -32601 for `resources/list` after negotiation, the same greeting for a 2025-06-18 `initialize`, -32600 for a numeric version, and the call's answer; broker exit 0; `inspect --capability-call` opens the retained response `{"result":{"content":[{"type":"text","text":"resolved"}]}}`, state `succeeded`, with its digest on the call row | The same removal: `left` begins `{"error":{"code":-32601,…},"id":1}` where `right` begins with the greeting (negotiation.rs:58) | + +Both tests passed again with the removal restored: `cargo test -p brokkr-cli --lib +broker::rpc` gave 95 passed, and the negotiation test passed in 9.96 s through real +dispatch, not skipped. `crates/brokkr-cli/tests/capability_broker/protocol.rs`'s +`a_server_off_its_pin_or_lacking_a_granted_tool…` still pins the child server's +2024-11-05 refusal, and `rpc/tests/masking.rs`'s id-masking case for an `initialize` +asking 2024-11-05 still ends on the masking cause before any answer. Both are unchanged +and pass in the suites below. + +## Item 2 — the compile's refusal (46.3, 46.4; SI2) + +`bundle/mcp.rs` gains `unbrokered`, an exhaustive match on the public `AdapterKind`: +Codex and dsh carry no broker, while Claude, Lanetally (Claude's grammar), exec and an +opaque command do. `sealed` asks it after `contribute`, on the set `brokering` reads from +the site's sealed outcome, and admits its cause through `McpFence::admit`, before the SI2 +verdict, the order the driver's `isolated` uses. The cause is the driver's own +`StrictCause::Unmeasured { provider, shape: "brokers" }` under the site's prefix. Nothing +in `crates/brokkr-protocol/src/adapters.rs`, `adapters/mcp.rs`, `adapters/mcp/admit.rs` or +`adapters/*.json` moved. + +| Test | Observed | Compiling removal and failure | +|---|---|---| +| `bundle::agent_tests::mcp_tests::a_brokered_grant_on_a_harness_carrying_no_broker_refuses_at_compile_with_the_drivers_cause` | Under the lifted fence, one office per provider (`opus`, `astra`, shipped dsh's `flash`), each proved on every MB2 axis with carriage measured, so no earlier check refuses: `requires` gives Claude `Ok(["library-docs"])`, Codex and dsh `Err("seat 'research' (office 'researcher') in realm 'private': provider '' has no measured strict MCP configuration for 'brokers'")`; `wants` refuses Codex and dsh the same; no holding compiles all three; the standing fence (macOS's) compiles all three holding it | `Some(AdapterKind::Codex \| AdapterKind::Dsh) => true`: `left: [Ok(["library-docs"]), Ok(["library-docs"]), Ok(["library-docs"])]` where `right` holds the two refusals (mcp_tests.rs:1662) | + +The refactor moves `the_pass_judges_each_resolved_candidate_and_refuses_in_the_sites_words`'s +shape and resolver setup into the shared builders `proved_boxed`, `proved_carriers` and +`holding_docs`, its assertions unchanged. With the removal restored, `cargo test -p +brokkr-runtime --lib bundle::agent_tests::mcp_tests` gave 22 passed and the whole runtime +lib 1451. + +## Item 3 — `Handoff` through real dispatch (46.5, closing 46.1) + +`windows.rs` held `Handoff` by a store read. It now holds it by `exec_held`, twice 200 ms +apart: a process below the broker in the tracer's stop (`t`) inside `execve` +(`/proc//syscall` 59 on x86_64, 221 elsewhere), whose only descriptor past its +standard streams is a pipe the broker also holds. A dump taken in the seat at the held +window showed that shape: bootstrap `comm` `brokkr`, state `t`, syscall 59, fds 0–2 its +stdio pipes and fd 39 `pipe:[252692612]`, which the broker held at fd 38. Its control +and ready pipes were gone. `exec_confined_with` calls `execve` only after `received` +returned the whole frame and its end, so this establishes the delivered-frame, +held-exec window with the status pipe open. + +| Test | Observed | Compiling removal and failure | +|---|---|---| +| `windows::a_harness_dying_at_a_held_handoff_ends_its_broker_before_its_deadline` | The harness SIGKILLed at the held window, its input kept by the keeper: the broker gone in 10–40 ms and, every startup window now, before its 30 s deadline from the harness's start; nothing below it or in its box's mount namespaces left; the ledger a prefix of or closed on `refused: handoff`; the park `awaiting_operator` on the handoff cause or the driver's lost result; `inspect`'s `last_seat` `work` with `result` null and no call row; the store read and the server never read | The broker's cancellation at the handoff wait (`exec_status` polling `(deadline, &\|\| false)`) together with the engine's sweep (`Host::REAL`'s `kill_group` and `kill` as no-ops): "Handoff: the broker lasted 30.041821621s" (windows.rs:485). Apart: the cancellation alone removed passes on the sweep (broker gone in 40 ms); the sweep alone removed fails on its park, which then adds "the attempt is not proven over: its process group … still had members after the kill", as U9b1b's store-read row did | +| `windows::the_engines_timeout_at_a_held_handoff_ends_it` | `limits.timeout_seconds` 25, the engine's sweep: parks "attempt exceeded its Ns deadline and was killed; its process tree is gone, and what it did is unknown"; the broker and everything below it gone at once; `inspect` keeps no call; reads held to the table | The attempt's deadline at `timeout_seconds * 4` (`engine/dispatch.rs`): it parks `Some("MCP server secret environment could not be delivered")`, the broker's own cause, not the timeout (windows.rs:579) | + +The tracer's delayed exec is allowed for. A process SIGKILLed inside the delayed +`execve` ends only when the 60 s delay does, wherever the tracer outlives the broker. In +the sweep-removed run, settlement took about 59.5 s. So `Window::delayed` adds `HELD_MS` +to `TEARDOWN` for the death case's settlement at `Handoff` alone. The broker's own end +keeps `TEARDOWN`, and the timeout case keeps `TEARDOWN`, as the sweep there kills the +tracer too. Since the 2026-11-01 ruling, `TEARDOWN` (30 s) equals MB3's whole startup, +so for every window where the startup deadline still stands (all but `Call`, by +`Window::starting`), `cancelled_at` now also asserts that the broker's lifetime from the +harness's start is under `STARTUP`. That is what binds the death case. `expired_at` +shares the new `lasted` and `STARTUP`, its assertions unchanged, and the module text +again claims every window. No product bound moved, and no panel proof was added (F8). +With every removal restored, all 17 `windows::` tests passed: the six death cases in +44.8 s, the eleven others in 104.7 s. + +## Inventory + +Production: `crates/brokkr-cli/src/broker/rpc.rs` (786 → 784 lines), +`crates/brokkr-cli/src/broker/rpc/calls.rs` (693 → 695), +`crates/brokkr-cli/src/broker/rpc/calls/request.rs` (139 → 140, an existing private child) +and `crates/brokkr-runtime/src/bundle/mcp.rs` (770 → 788). Tests: +`crates/brokkr-cli/src/broker/rpc/tests.rs` (1,995), +`crates/brokkr-cli/tests/capability_broker/enabled.rs` (691), +`crates/brokkr-cli/tests/capability_broker/enabled/negotiation.rs` (new, 82, registered +as `mod negotiation;`), `crates/brokkr-cli/tests/capability_broker/enabled/windows.rs` +(686) and `crates/brokkr-runtime/src/bundle/agent_tests/mcp_tests.rs` (1,771). Each has +its row in `quality/file-lines.txt`. There were no test moves, no new suppression, no +new public item (`quality/ratchet.sh api`: "public API holds"), and no witness or +compose pin moved: the runtime's `it` target, `witness_digests` included, passed +unblessed. Documents: MB3's text and scenario, `tasks.md` (46.1, 46.3–46.5 and two +status lines), `evidence/U9b1b.md`'s Tasks paragraph and this file. + +## Gates on the tested head + +| Gate | Result | +|---|---| +| `cargo fmt --all -- --check`, `git diff --check c21ab42d9`, `typos --hidden` | clean | +| `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` | clean | +| `openspec validate --all --strict` | 36 passed, 0 failed | +| `quality/ratchet.sh files`, `clones`, `api` | file size, duplication and public API hold | +| `compile --bundle recipes/self` and `recipes/verify` | both compile; policy sweeps 48/11 and 16/5, 0 unruled | +| Workspace tests, crate by crate under `timeout 590` | core, view, store and bridge 16 targets ok; protocol 4 ok; runtime lib 1451, `capability_launch` 76, `it` 144, `operated_repo` 1, `queued_launch` 8; seatbelt-probe 99; CLI lib 1110, `it` 848 outside `capability_broker::enabled`, the enabled suite 55 plus the 17 `windows::`, `driver_conformance` 27, `transcript_surfaces` 15, every `heap_*` target ok; no failure | + +## Pending + +`scripts/coverage-exact.sh` and `quality/ratchet.sh crap` were not run in the seat. Boundary +tests need a namespace the box will not nest, so the controller's host and CI run them. +The macOS legs of CI are pending: the change adds no Linux-only production code, and its +new tests sit in the Linux-only `enabled` suite or under the test fence. The host +evidence is the controller's and pending: a real Claude seat under `no_new_privs` +calling the granted tool through the broker, with the call and its response read back +through `inspect --capability-call`. + +## Notes for the council + +A wanted brokered grant on Codex or dsh refuses at compile rather than dropping. That is +the design's word ("The compile now refuses such a site"), and it matches the driver, +which refuses whatever the ask. The before-deadline check in `cancelled_at` also binds +U9b1b's five other startup windows. They passed under it. It is a stronger assertion +in this unit's test file, not a change to their setup. SI2's text in +`specs/strict-mcp-isolation/spec.md` is not edited: the row names only MB3's. A survey +keeps the site's refusal in `refused`, which `doctor/capabilities/mcp.rs`'s +`report_refused` reads, so the doctor sees the new cause there. It is not added to +the per-serving SI2 verdicts in `Planned.strict`, and no doctor test pins it. diff --git a/openspec/changes/decision-0065-capabilities-slice-two/tasks.md b/openspec/changes/decision-0065-capabilities-slice-two/tasks.md index 8ab1334bc..674fc0d72 100644 --- a/openspec/changes/decision-0065-capabilities-slice-two/tasks.md +++ b/openspec/changes/decision-0065-capabilities-slice-two/tasks.md @@ -628,17 +628,18 @@ Dependencies: U8j. Files and scope: [U8k](design.md#u8k--mount-the-sealed-source Dependencies: U9b1a for U9b1b, by operator ruling 2026-10-30; U1d3, U8h, U8i, U8j, U8k, U1d4, U1d5 and U1d6 for U9b1a, which closes no task. Files and scope: [U9b1](design.md#u9b1--enable-the-proved-namespace-path) (operator ruling 2026-10-12), [U9b1a](design.md#u9b1a--lift-the-mcp-compile-fence-where-the-namespace-path-is-proved) and [U9b1b](design.md#u9b1b--prove-the-enabled-namespace-path-end-to-end). -- [ ] 46.1 (U9b1b by operator ruling 2026-10-30; [GP1](specs/gate-capability-policy/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [MB2](specs/mcp-capability-broker/spec.md), [MB4](specs/mcp-capability-broker/spec.md), [CR2](specs/capability-response-retention/spec.md), [CR4](specs/capability-response-retention/spec.md), [SD3](specs/slice-two-delivery/spec.md), [MB5](specs/mcp-capability-broker/spec.md)) Lift only the global MCP compile fence after all prior proofs, activating D3's namespace, gate, strictness, carriage, secret-read, box admission/readiness, evidence and D11 rules. Quiesce older same-worktree engines before enabling managed-writer coordination. The enabling matrix also traverses all boxed-server seams: singleton system and user-installed package entries with ordinary protected system hard links intact, SD4's measured host budgets, every reach mode and containment direction, resolution ancestors, hard links and bind aliases, store exclusion including empty/missing stores, bounded observation/readiness/handoff, and actual kernel shebang execution. Pin all pre-secret refusals to zero lookups/starts and distinguish later delivery/exec failures. A success-reporting harness cannot hide any zero-call box failure. Real Linux proof includes private tmpfs per server, local/shared network with hands still network-false, safe host loader environment, allowed loading bindings and cancellation at every startup window. macOS proves its existing refusal; no successful box is claimed there. Verify: Real compile/launch/broker/fold/inspect with fake dialect; exact native/MCP gate reads/writes/explicit-office-egress required/wanted outcomes, same-name MCP holding keeps native power OFF, zero-grant sibling and exec cannot alter evidence, unsafe secret reads refuse, and quota/recovery/cold/fallback/member/step cases bind. Include the success-reporting harness after zero-call version failure, post-call fatal protocol and 4,096-call exhaustion, plus scalar-secret refusal on the real retention/inspect path; pin exact causes, counts and absent unsafe bodies. Add the separate HOME/TMPDIR collisions through real compile and selected launch, asserting the startup cause, zero store lookup/child start, and normal DOCS_TOKEN/private-directory control. Independent removal of reserved-name validation must fail. Repeat the cross-worktree start-order/concurrency/crash matrix, complete-frame EOF and private exec-status controls through real dispatch. Quiesce older writers whose reach overlaps protected sources even from another worktree; all engine/broker observations count toward SD4's total startup budget. - Status ([evidence/U9b1b.md](evidence/U9b1b.md)): open by operator ruling 2026-10-31 ("B"). Run 0065-slice-two-unit-u9b1b-see-th-9d7328d4's third review (M1) found the harness's death and the engine's timeout proved through real dispatch at every startup window but `Handoff`, which only the startup deadline ends. U9b1b lands by hand with this task open; U9b1c proves both `Handoff` cases through real dispatch, with their compiling removals, and ticks it. +- [x] 46.1 (U9b1b by operator ruling 2026-10-30; [GP1](specs/gate-capability-policy/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [MB2](specs/mcp-capability-broker/spec.md), [MB4](specs/mcp-capability-broker/spec.md), [CR2](specs/capability-response-retention/spec.md), [CR4](specs/capability-response-retention/spec.md), [SD3](specs/slice-two-delivery/spec.md), [MB5](specs/mcp-capability-broker/spec.md)) Lift only the global MCP compile fence after all prior proofs, activating D3's namespace, gate, strictness, carriage, secret-read, box admission/readiness, evidence and D11 rules. Quiesce older same-worktree engines before enabling managed-writer coordination. The enabling matrix also traverses all boxed-server seams: singleton system and user-installed package entries with ordinary protected system hard links intact, SD4's measured host budgets, every reach mode and containment direction, resolution ancestors, hard links and bind aliases, store exclusion including empty/missing stores, bounded observation/readiness/handoff, and actual kernel shebang execution. Pin all pre-secret refusals to zero lookups/starts and distinguish later delivery/exec failures. A success-reporting harness cannot hide any zero-call box failure. Real Linux proof includes private tmpfs per server, local/shared network with hands still network-false, safe host loader environment, allowed loading bindings and cancellation at every startup window. macOS proves its existing refusal; no successful box is claimed there. Verify: Real compile/launch/broker/fold/inspect with fake dialect; exact native/MCP gate reads/writes/explicit-office-egress required/wanted outcomes, same-name MCP holding keeps native power OFF, zero-grant sibling and exec cannot alter evidence, unsafe secret reads refuse, and quota/recovery/cold/fallback/member/step cases bind. Include the success-reporting harness after zero-call version failure, post-call fatal protocol and 4,096-call exhaustion, plus scalar-secret refusal on the real retention/inspect path; pin exact causes, counts and absent unsafe bodies. Add the separate HOME/TMPDIR collisions through real compile and selected launch, asserting the startup cause, zero store lookup/child start, and normal DOCS_TOKEN/private-directory control. Independent removal of reserved-name validation must fail. Repeat the cross-worktree start-order/concurrency/crash matrix, complete-frame EOF and private exec-status controls through real dispatch. Quiesce older writers whose reach overlaps protected sources even from another worktree; all engine/broker observations count toward SD4's total startup budget. + Status ([evidence/U9b1b.md](evidence/U9b1b.md)): open by operator ruling 2026-10-31 ("B"). Run 0065-slice-two-unit-u9b1b-see-th-9d7328d4's third review (M1) found the harness's death and the engine's timeout proved through real dispatch at every startup window but `Handoff`, which only the startup deadline ends. U9b1b lands by hand with this task open; U9b1c proves both `Handoff` cases through real dispatch, with their compiling removals, and ticks it. Closed by U9b1c's 46.5 ([evidence/U9b1c.md](evidence/U9b1c.md)). - [ ] 46.2 (U9b1b by operator ruling 2026-10-30; [GP1](specs/gate-capability-policy/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [MB2](specs/mcp-capability-broker/spec.md), [MB4](specs/mcp-capability-broker/spec.md), [CR2](specs/capability-response-retention/spec.md), [CR4](specs/capability-response-retention/spec.md), [SD3](specs/slice-two-delivery/spec.md), [SD2](specs/slice-two-delivery/spec.md), [SD4](specs/slice-two-delivery/spec.md), [MB5](specs/mcp-capability-broker/spec.md)) Through real compile, dispatch, broker, fold and inspect in the owning suites, repeat 46.1 and every boxed-server scenario-map row. Independently bind cross-worktree atomic admission, complete writer reach, reservation retention after engine death, receiver EOF, both private failure tags and initialization after status EOF; no earlier failing case proves a later cause. Use the success-reporting harness to pin failed-attempt causes and zero invented call rows, with healthy controls. Record all mandatory engine preparation and broker reobservation timings separately and together under SD4's budgets on both Linux profiles; deadline/cancellation proofs leave no detached admission worker. Audit current-head U0 qualification, unit removal evidence, real namespace results and macOS refusal before lifting the compile fence. Restore all mutations and run the shared verification duty; missing host evidence leaves this task pending. ## 46a. U9b1c — Negotiate the MCP protocol version and refuse unbrokerable grants at compile Dependencies: U9b1b. Files and scope: [U9b1c](design.md#u9b1c--negotiate-the-mcp-protocol-version-and-refuse-unbrokerable-grants-at-compile) (controller ruling 2026-10-31 under the operator's standing ruling of 2026-10-28). -- [ ] 46.3 (U9b1c; [MB3](specs/mcp-capability-broker/spec.md), [SI2](specs/strict-mcp-isolation/spec.md)) Have the broker answer a harness's `initialize` that asks another protocol version with its own 2025-06-18 result, as MCP's version negotiation says, holding the session to 2025-06-18; refuse at compile a brokered grant on a provider whose fragment carries no broker, with the driver's own cause. Verify: a harness asking 2025-11-25 completes a granted call through the real broker; Codex and dsh sites holding a brokered grant refuse at compile exactly; a Claude site compiles. -- [ ] 46.4 (U9b1c; [MB3](specs/mcp-capability-broker/spec.md), [SD2](specs/slice-two-delivery/spec.md)) In the owning suites listed for this row, record independent exact assertions, compiling removal failures and restored passes for task 46.3. Apply the shared verification duty above. -- [ ] 46.5 (U9b1c by operator ruling 2026-10-31; [MB5](specs/mcp-capability-broker/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [SD2](specs/slice-two-delivery/spec.md)) Through real dispatch, end a broker's startup held at the `Handoff` window by the harness's death and by the engine's timeout of the attempt, each with its exact outcome, `inspect`'s view and nothing left below the broker, and record a compiling removal and restored pass for each; then tick 46.1, whose remainder this is. +- [x] 46.3 (U9b1c; [MB3](specs/mcp-capability-broker/spec.md), [SI2](specs/strict-mcp-isolation/spec.md)) Have the broker answer a harness's `initialize` that asks another protocol version with its own 2025-06-18 result, as MCP's version negotiation says, holding the session to 2025-06-18; refuse at compile a brokered grant on a provider whose fragment carries no broker, with the driver's own cause. Verify: a harness asking 2025-11-25 completes a granted call through the real broker; Codex and dsh sites holding a brokered grant refuse at compile exactly; a Claude site compiles. +- [x] 46.4 (U9b1c; [MB3](specs/mcp-capability-broker/spec.md), [SD2](specs/slice-two-delivery/spec.md)) In the owning suites listed for this row, record independent exact assertions, compiling removal failures and restored passes for task 46.3. Apply the shared verification duty above. +- [x] 46.5 (U9b1c by operator ruling 2026-10-31; [MB5](specs/mcp-capability-broker/spec.md), [MB3](specs/mcp-capability-broker/spec.md), [SD2](specs/slice-two-delivery/spec.md)) Through real dispatch, end a broker's startup held at the `Handoff` window by the harness's death and by the engine's timeout of the attempt, each with its exact outcome, `inspect`'s view and nothing left below the broker, and record a compiling removal and restored pass for each; then tick 46.1, whose remainder this is. + Status ([evidence/U9b1c.md](evidence/U9b1c.md)): 46.3–46.5 proved on this tree; exact coverage, macOS CI and the controller's real-Claude host check are pending there. ## 47. U9b2 — Publish implemented scope and contract guidance diff --git a/quality/file-lines.txt b/quality/file-lines.txt index 60d3da06b..8addf8ae3 100644 --- a/quality/file-lines.txt +++ b/quality/file-lines.txt @@ -466,7 +466,7 @@ 235 crates/brokkr-cli/tests/capability_broker/enabled/reach.rs 409 crates/brokkr-cli/tests/capability_broker/enabled/sites.rs 201 crates/brokkr-cli/tests/capability_broker/enabled/transport.rs - 579 crates/brokkr-cli/tests/capability_broker/enabled/windows.rs + 686 crates/brokkr-cli/tests/capability_broker/enabled/windows.rs 994 crates/brokkr-cli/tests/capability_broker/handoff.rs 1028 crates/brokkr-cli/tests/capability_broker/ledger.rs 1047 crates/brokkr-cli/tests/capability_broker/observer.rs