From 88fc03fe7fe70258013a8473c003bdc71abb5dfe Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:18:52 +0200 Subject: [PATCH 1/7] CONTRACT: keep interactive tool rosters stable across turns --- .changeset/stable-interactive-tool-rosters.md | 6 ++ AGENTS.md | 2 +- docs/decisions/README.md | 2 +- docs/decisions/async-collaboration.md | 4 +- .../decisions/claude-2.1.287-compatibility.md | 4 +- docs/decisions/claude-code-runtime.md | 12 +-- docs/decisions/design-kit/show-block-brief.md | 2 +- docs/decisions/governed-writable-workers.md | 6 +- docs/decisions/policy-write-boundary.md | 2 +- docs/decisions/voice-permission.md | 30 +++++-- docs/integration-contract/mcp.md | 15 ++++ .../plans/async-collaboration-requirements.md | 6 +- docs/plans/async-collaboration.md | 10 +-- docs/plans/policy-write-boundary.md | 4 +- .../ui-backend-claude/src/ask-user-tool.ts | 21 ++++- .../src/brain-application-tools.ts | 4 +- .../ui-backend-claude/src/permission-hooks.ts | 18 +++- packages/ui-backend-claude/src/sdk-options.ts | 24 ++--- .../src/tool-availability.ts | 27 ++++++ .../tests/confirm-archive.test.ts | 6 +- .../tests/edited-approval.test.ts | 5 +- .../tests/fixtures/stable-roster-probe.ts | 89 +++++++++++++++++++ .../tests/no-grant-surface.test.ts | 5 +- .../tests/sdk-options-mcp.test.ts | 10 +-- .../tests/stable-roster-runtime.test.ts | 34 +++++++ .../tests/stable-roster.test.ts | 66 ++++++++++++++ .../tests/voice-posture.test.ts | 8 +- packages/ui-backend-pi/src/backend-options.ts | 6 ++ packages/ui-backend-pi/src/bridge-tools.ts | 2 +- packages/ui-backend-pi/src/permission-gate.ts | 22 +++++ packages/ui-backend-pi/src/session-pool.ts | 4 +- .../tests/fixtures/stable-roster-probe.ts | 64 +++++++++++++ .../tests/policy-membership.test.ts | 2 +- .../tests/stable-roster-runtime.test.ts | 25 ++++++ .../ui-backend-pi/tests/stable-roster.test.ts | 34 +++++++ .../tests/tools-permission.test.ts | 1 + scripts/check-citations.ts | 4 +- 37 files changed, 516 insertions(+), 70 deletions(-) create mode 100644 .changeset/stable-interactive-tool-rosters.md create mode 100644 packages/ui-backend-claude/src/tool-availability.ts create mode 100644 packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts create mode 100644 packages/ui-backend-claude/tests/stable-roster-runtime.test.ts create mode 100644 packages/ui-backend-claude/tests/stable-roster.test.ts create mode 100644 packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts create mode 100644 packages/ui-backend-pi/tests/stable-roster-runtime.test.ts create mode 100644 packages/ui-backend-pi/tests/stable-roster.test.ts diff --git a/.changeset/stable-interactive-tool-rosters.md b/.changeset/stable-interactive-tool-rosters.md new file mode 100644 index 0000000000..d0a1766052 --- /dev/null +++ b/.changeset/stable-interactive-tool-rosters.md @@ -0,0 +1,6 @@ +--- +"@schlessera/brain-backend-claude": minor +"@schlessera/brain-backend-pi": minor +--- + +Keep interactive tool schemas stable across posture, strict fallback and temporary host capability changes. Refuse unavailable calls before privileged handlers or approval cards, preserving voice and sandbox authority. diff --git a/AGENTS.md b/AGENTS.md index 9518f27261..69e6cb9173 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ on, links to or describes them. declared with `defineModule({ name, configSchema, setup })`. Run `brain module lint` before submitting one. - Cite code from docs and source comments as an anchor followed by its range: - (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`), + (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`), starting on the anchor's line. **A PR that moves lines under a citation owns that citation**, wherever it is recorded; find them with `git grep -n '.ts:' -- docs packages`. `tests/decision-citations.test.ts` diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5ab2d5e0a9..5c46cb89bc 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -169,7 +169,7 @@ it is told something confidently wrong. So name what you point at, and let the range follow it: ```md -(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`) +(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`) ``` - **The anchor** is the code span immediately before the citation, joined to it diff --git a/docs/decisions/async-collaboration.md b/docs/decisions/async-collaboration.md index 85c641d9fb..ee9ee3a3ab 100644 --- a/docs/decisions/async-collaboration.md +++ b/docs/decisions/async-collaboration.md @@ -75,7 +75,7 @@ optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/serve not a claim of filesystem or network containment. The ordinary Claude assembly loads project settings and appends bridge tools -(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-264`). +(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`). Its environment is already filtered (`envSnapshot`, `packages/ui-backend-claude/src/config/env.ts:182-190`), with profile credentials and operator extras. An autonomous envelope needs its own narrower credential @@ -85,7 +85,7 @@ is historical. Preserve subscription billing and the selected runtime identity. Pi disables built-in tools but currently loads resources and extensions (`createSessionResources`, `packages/ui-backend-pi/src/session-resources.ts:29-144`) and gates extension calls (`createPermissionGate`, -`packages/ui-backend-pi/src/permission-gate.ts:77-104`). Removing four curated +`packages/ui-backend-pi/src/permission-gate.ts:95-124`). Removing four curated tools cannot prove that extensions, MCP, scratch writers or in-process code have no egress or write access. Both first-party runtimes owe executable evidence; the plan's earlier “pi is easy” claim is not a containment result. diff --git a/docs/decisions/claude-2.1.287-compatibility.md b/docs/decisions/claude-2.1.287-compatibility.md index bfd9ba308f..de328bb397 100644 --- a/docs/decisions/claude-2.1.287-compatibility.md +++ b/docs/decisions/claude-2.1.287-compatibility.md @@ -124,9 +124,9 @@ call, including when settings or a rewrite could otherwise auto-approve it. The backend builds the actual turn options and production permission handlers (`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-202`). The explicit ask lives in -(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`) +(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`) and is registered under enforcement in -(`...(enforced ?`, `packages/ui-backend-claude/src/permission-hooks.ts:529-532`). +(`...(enforced ?`, `packages/ui-backend-claude/src/permission-hooks.ts:540-543`). `scripts/measure-claude-enforcement.ts` runs `createClaudeBackend().startTurn()` and forwards to the SDK resolved from that backend. Its query wrapper only diff --git a/docs/decisions/claude-code-runtime.md b/docs/decisions/claude-code-runtime.md index aef745306b..004c112b48 100644 --- a/docs/decisions/claude-code-runtime.md +++ b/docs/decisions/claude-code-runtime.md @@ -47,7 +47,7 @@ next to it. `packages/ui-server/src/agent/backend.ts:497`), read back as a string (`const claudeCodePath`, `packages/ui-backend-claude/src/module.ts:266-270`) and handed to the SDK (`backend.claudeCodePath`, - `packages/ui-backend-claude/src/sdk-options.ts:263`). Because of the `||` + `packages/ui-backend-claude/src/sdk-options.ts:262`). Because of the `||` default the value was never empty, so **the server always overrode the SDK's own binary**. The variable is withheld from every subprocess (`CLAUDE_CODE_PATH: NONE`, @@ -276,7 +276,7 @@ a permission boundary: - **A refusal would not buy what it appears to.** The measured version is not known-safe either: the list of mechanisms that skip `canUseTool` is what has been measured, not a closed set (`AT LEAST three`, - `permission-hooks.ts:150-153`). A version gate would separate "probed" from "not + `permission-hooks.ts:151-154`). A version gate would separate "probed" from "not probed", not "safe" from "unsafe", and it would say the second thing to whoever reads it. - **It would block the path a CLI fix takes.** The built-in binary moves only @@ -356,7 +356,7 @@ so it holds itself to these rules: about another. - **Composition, not only parts.** The enforcement hook's `ask` must leave another hook's `updatedInput` intact (`An explicit "ask" beats all three`, - `permission-hooks.ts:177-179`; `(e) a hook's`, + `permission-hooks.ts:178-180`; `(e) a hook's`, `allowlist-enforcement.test.ts:18-21`). That is its own case: a rewrite, an `ask` and a `canUseTool` decision in one call, asserting the input the callback saw and the input that executed. @@ -383,11 +383,11 @@ until re-measured. | Site | What it asserts | Re-checked by | | --- | --- | --- | -| `DO NOT WEAKEN THIS INTO A FALLTHROUGH`, `packages/ui-backend-claude/src/permission-hooks.ts:149-180` | Three things admit a tool before `canUseTool` — the safe-command classifier (`echo hi` with an empty allowlist), a built-in tool's own check (`ToolSearch`), a project-settings `PreToolUse` hook returning `allow` — and an explicit `ask` beats all three. `permissions.allow` rules and `defaultMode: "bypassPermissions"` do not bypass. | Probe cases for each of the three, each with and without the `ask`, plus the two negative controls. | +| `DO NOT WEAKEN THIS INTO A FALLTHROUGH`, `packages/ui-backend-claude/src/permission-hooks.ts:150-181` | Three things admit a tool before `canUseTool` — the safe-command classifier (`echo hi` with an empty allowlist), a built-in tool's own check (`ToolSearch`), a project-settings `PreToolUse` hook returning `allow` — and an explicit `ask` beats all three. `permissions.allow` rules and `defaultMode: "bypassPermissions"` do not bypass. | Probe cases for each of the three, each with and without the `ask`, plus the two negative controls. | | `Both hooks exist to rewrite`, `packages/ui-backend-claude/src/input-rewrite-hooks.ts:8-15` | A hook's `updatedInput` applies with no decision, and the permission path sees the rewritten input. | Probe case: rewrite with no decision; assert the executed input and the `canUseTool` input. | | `The runtime precedence modelled by`, `packages/ui-backend-claude/tests/allowlist-enforcement.test.ts:11-20` | The precedence `runToolCall` models, (a)–(e). The test cannot re-measure it. | The cases above plus the composition case. A changed result changes the model in the test in the same PR. | | `the one answer that beats`, `packages/ui-backend-claude/tests/no-grant-surface.test.ts:13-18` | The same three opinions, and that `ask` is what forces the decision. | Same probe cases. | -| `That third vector is stated here`, `docs/decisions/voice-permission.md:629-655` | An in-process `deny` beats a project-settings `allow`. | Probe case: settings `allow` against in-process `deny`, with the positive control of the settings hook alone running the tool. | +| `That third vector is stated here`, `docs/decisions/voice-permission.md:645-671` | An in-process `deny` beats a project-settings `allow`. | Probe case: settings `allow` against in-process `deny`, with the positive control of the settings hook alone running the tool. | | `Three measured examples from the Claude SDK`, `docs/extending/agent-backends.md:291-304` | The same three mechanisms and the `ask`, restated for backend authors with no version attached. | Updated in the same PR as the constant whenever a probe result changes. | | `createSdkMcpServer({ alwaysLoad: true })`, `docs/decisions/design-kit/tool-loading.md:21-44` (D44) | Two different kinds of claim. That `createSdkMcpServer({ alwaysLoad })` stamps `_meta["anthropic/alwaysLoad"]` is SDK behaviour, asserted keylessly by `"anthropic/alwaysLoad"`, `packages/ui-backend-claude/tests/sdk-options-mcp.test.ts:82` and `"anthropic/alwaysLoad"`, `tests/bridge-tools.test.ts:816,834`. That the CLI honours the stamp, and that first-frame latency did not move, is CLI behaviour. | The SDK half by the existing tests. The CLI half needs a live run of both arms — stamp set and unset — on the new pair, recording the pair from `init` and observing whether the bridge tools reached the model undeferred. `scripts/measure-show-block.ts` can run either arm (with and without `--always-load`; `ALWAYS_LOAD`, `scripts/measure-show-block.ts:304,369`), but it records no version and nothing in it compares the two arms or checks deferral, so it does not re-check this as it stands. Extending it is part of #209. `--tokens` prices schemas through the API and never runs the CLI, so it re-checks nothing here. | @@ -408,7 +408,7 @@ brain-hosting-template. None of the live sites is accepted as unverifiable. What stays unverifiable is the absence of a *fourth* mechanism — `AT LEAST three`, -`permission-hooks.ts:150-153` already says three is what has been measured, not a +`permission-hooks.ts:151-154` already says three is what has been measured, not a closed set — and no probe can close that. ## Where the work goes diff --git a/docs/decisions/design-kit/show-block-brief.md b/docs/decisions/design-kit/show-block-brief.md index 8cf44c499d..25427b110e 100644 --- a/docs/decisions/design-kit/show-block-brief.md +++ b/docs/decisions/design-kit/show-block-brief.md @@ -107,7 +107,7 @@ no route to `trend`, `bars` or `contact`: **Why the no-brief arm is near zero, which is the actual finding.** Not reluctance. The SDK **defers an MCP server's tools behind tool search by default** — they are not in the model's context at all until it runs -`ToolSearch` — and `packages/ui-backend-claude/src/ask-user-tool.ts:107` does +`ToolSearch` — and `packages/ui-backend-claude/src/ask-user-tool.ts:109` does not pass `alwaysLoad`, so this is production's behaviour and the harness inherits it. Over the two runs below — **run A**, 108 turns, taken before the turn budget was enforced and therefore losing none, and **run B**, the 108-turn diff --git a/docs/decisions/governed-writable-workers.md b/docs/decisions/governed-writable-workers.md index bda84efa87..5cf3207abf 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -969,8 +969,8 @@ classification: indexing. `write_file` and `edit_file` remain the way to write under `strict`. - **Native tools return under `governed`.** In Claude the - `Write`/`Edit`/`NotebookEdit` disallow is lifted - (`disallowedTools`, `packages/ui-backend-claude/src/sdk-options.ts:226`). In + `Write`/`Edit`/`NotebookEdit` stay registered under #2048 A; strict/voice + refusal moves to call time (`toolAvailabilityRefusal`, `packages/ui-backend-claude/src/tool-availability.ts:16-27`). In pi, the direct tools and a `bash` whose working directory is the brain root return (`const bash`, `packages/ui-backend-pi/src/tools.ts:541`). - **Approvals do not change.** Confirmation patterns and destructive-operation @@ -991,7 +991,7 @@ canonical bytes for a frozen view (`createWorkerFileObserver`, read cannot establish a new version. Claude's native `file_unchanged` response also preserves the previous observation: it supplied no new bytes (`const finishNativeFile`, -`packages/ui-backend-claude/src/permission-hooks.ts:89`). +`packages/ui-backend-claude/src/permission-hooks.ts:90`). Claude keeps these observations in its backend-owned session map (`const fileVersionsBySession`, diff --git a/docs/decisions/policy-write-boundary.md b/docs/decisions/policy-write-boundary.md index ed776d6b5c..87a3bdbc71 100644 --- a/docs/decisions/policy-write-boundary.md +++ b/docs/decisions/policy-write-boundary.md @@ -43,7 +43,7 @@ application, refusal and recovery. This is a change to the execution architecture. Current Claude assembly creates SDK options and in-process bridge tools (`createClaudeSdkTurn`, -`packages/ui-backend-claude/src/sdk-options.ts:85-262`); pi builds its worker-local resource +`packages/ui-backend-claude/src/sdk-options.ts:85-261`); pi builds its worker-local resource loader and calls `reload()` (`createSessionResources`, `packages/ui-backend-pi/src/session-resources.ts:29-113`). A child-only wrapper cannot be treated as proof for those parent-side paths. The investigation records diff --git a/docs/decisions/voice-permission.md b/docs/decisions/voice-permission.md index 574972191c..4ba13a3207 100644 --- a/docs/decisions/voice-permission.md +++ b/docs/decisions/voice-permission.md @@ -311,7 +311,7 @@ the matched pattern's effect, so the same words are already on the card. they are exhaustive.** A kind-`tool` request has no pattern at all — the description is the SDK's own (`canUseTool` passes `description: opts.description` straight through to `createToolPermissionRequest`, -`permission-hooks.ts:272-282`) and is not +`permission-hooks.ts:281-291`) and is not written to be heard. And `ClaudeBackendOptions.confirmBashPatterns` (`Regex sources`, `packages/ui-backend-claude/src/options.ts:53-63`) lets a deployment supply its own patterns, which have no phrase when given as @@ -517,7 +517,23 @@ section. What the record requires is narrower and mechanism-agnostic: > expressible in it as a second named membership. A voice turn and an unattended > turn must differ in *which set they name*, not in *how the set is enforced*. -On the merits, availability control is the better mechanism and the voice +**Superseded for interactive/voice sessions, 2026-10-16 (#2048 A).** The +following availability-control preference records the original rationale. The +selected implementation now keeps the full deployment-configured roster fixed, +including on a voice-first session, to preserve provider prompt-cache prefixes +when a later typed turn has more capabilities. Schema presence is never a grant: +mandatory call-time hooks deny strict/voice native writes and unavailable bridge +calls before handlers or permission cards; remembered/settings allows cannot +remove those denies. The same per-tool negative runtime and mutation evidence +required below remains mandatory. Missing handlers and no-grant surfaces also +refuse at call time. This supersedes the visual-tool withholding mechanism +(including the mask discussion below), while preserving its reason: nobody may +be left waiting on a card they cannot operate. Autonomous restricted membership +is unchanged. #2022 coordinates native auto-allow on governed typed turns without +weakening stale-read, protected-path or write-lock checks; #2053 records the +broader append-only provider-history rule. + +Historically, availability control was preferred and the voice posture should follow U15 there if U15 lands first. It removes three problems this record otherwise has to legislate around: the runtime bypasses below stop being reachable, the bridge-tool append stops being an exclusion nobody can @@ -582,7 +598,7 @@ matters because a reader cannot otherwise tell a live hazard from a fixed one. without granting (`RewriteHookOptions`, `packages/ui-backend-claude/src/input-rewrite-hooks.ts:18-32`; `!mayGrant`, `:71-79`; wired at their `agentHook`/`rtkHook` constructions, - `const agentHook`, `permission-hooks.ts:497-516`). + `const agentHook`, `permission-hooks.ts:508-527`). The premise the old grant rested on was measured false in the process: `permissionDecision: "allow"` was never what made `updatedInput` take effect, so the grant was a side effect and it is the @@ -599,7 +615,7 @@ matters because a reader cannot otherwise tell a live hazard from a fixed one. before the lookup, which is what this record asked for. **Closed, by #110.** The enforcement hook still answers `ask`, not `deny` -(`enforcementHook`, `permission-hooks.ts:182-205`) — that is what beats the +(`enforcementHook`, `permission-hooks.ts:183-212`) — that is what beats the runtime's own shortcuts below, and replacing it would reopen all three. What changed is the decision the `ask` forces. A turn declares `StartTurnRequest.noGrantSurface`, and both @@ -619,7 +635,7 @@ with a `BackendRequestError` (`assertTurnPosture`, codebase's own shortcuts was never sufficient: the runtime has permission opinions of its own that also land before `canUseTool` (the block above `enforcementHook`, `DO NOT WEAKEN THIS INTO A FALLTHROUGH`, -`permission-hooks.ts:149-180`, with the measurements in #154) — a safe-command +`permission-hooks.ts:150-181`, with the measurements in #154) — a safe-command classifier (`echo hi` runs with an **empty** `allowedTools` and the callback is never consulted), a built-in tool's own check (`ToolSearch` executed twice under the same conditions), and a `PreToolUse` hook in the project settings this @@ -672,8 +688,8 @@ Bash call through `canUseTool` first: the PreToolUse `mutatingHook` fires before permission evaluation and evaluates the confirm patterns against `commandAllowed`, which adds `Bash` back unconditionally (`commandAllowed`, -`permission-hooks.ts:217-219`, and the call in `mutatingHook` that reads it, -`decideToolPermission`, `:281-288`). So a +`permission-hooks.ts:224-226`, and the call in `mutatingHook` that reads it, +`decideToolPermission`, `:290-297`). So a destructive shell command in a voice turn raises a `command` request — and parks — before the tool grant is ever considered. A fail-closed rule written only for kind `tool` would leave exactly the calls this whole record is about diff --git a/docs/integration-contract/mcp.md b/docs/integration-contract/mcp.md index 45d8f18a97..6735247e6a 100644 --- a/docs/integration-contract/mcp.md +++ b/docs/integration-contract/mcp.md @@ -209,6 +209,21 @@ The Markdown byte cap and supported file kinds above remain unchanged. ### Chat-UI in-process tools (`mcp__brain-ui__*`) +**Interactive roster stability (#2048).** Each backend offers its complete +first-party deployment tool roster in deterministic order on every interactive +turn, including voice-first sessions. A temporary missing handler, no-grant +surface or strict sandbox fallback does not remove tools, schemas or descriptions. +Registration grants no authority. Unavailable bridge calls return a model-readable +refusal before opening a permission card or invoking the privileged handler; +voice and no-grant turns refuse visual pickers, image masks, policy proposals and +inbox mutations. Claude keeps native `Write`, `Edit` and `NotebookEdit` registered +and refuses them at call time on strict/voice turns, including settings or +remembered allows. Governed typed calls retain their stale-read and write-lock +checks. Pi still rejects voice dispatch before a provider request: the stable +roster does not add a voice posture. Restricted autonomous rosters remain exact +server authority and are outside this interactive stability rule. Existing tool +names, inputs and result shapes are unchanged. + The chat-UI backends register an in-process MCP server under the `brain-ui` key; its tool names are equally stable. `ask_user`, `ask_user_form` (additive in 0.40.0), `ask_user_rank` (additive in 0.40.0), `ask_user_list` (additive in 0.40.0), `get_current_location` and `request_image_mask` bridge to the diff --git a/docs/plans/async-collaboration-requirements.md b/docs/plans/async-collaboration-requirements.md index ad0d90cf0c..e60219af08 100644 --- a/docs/plans/async-collaboration-requirements.md +++ b/docs/plans/async-collaboration-requirements.md @@ -279,7 +279,7 @@ Stated before the requirements because five of them derive from it. `DEFAULT_ALLOWED_TOOLS` auto-allows `Bash`, `Write`, `Edit`, `WebFetch`, `WebSearch`, and `Agent` (`export const DEFAULT_ALLOWED_TOOLS = [`, `packages/ui-backend-claude/src/tool-policy.ts:27-80`), and auto-allowed tools bypass `canUseTool` entirely — the backend says so where it explains why the write lock had to - move into a `PreToolUse` hook (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`). Existing mandatory tool/no-grant posture closes measured permission bypasses, + move into a `PreToolUse` hook (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`). Existing mandatory tool/no-grant posture closes measured permission bypasses, but it does not express the full filesystem/network envelope. Building it requires: the SDK's tool **availability** control (`tools`, not merely `allowedTools`), a scrubbed environment carrying only inference credentials and minimum runtime variables (the current environment is filtered but retains operator/profile extras — (`export function envSnapshot(`, `packages/ui-backend-claude/src/config/env.ts:182-190`)), @@ -289,7 +289,7 @@ Stated before the requirements because five of them derive from it. AGENTS.md ("Testing expectations") records for the renderer. - R29. **Autonomous runs do not inherit ambient project configuration.** Claude loads project settings/instructions and also appends explicit bridge tools - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-264`). If containment fails once, `.claude/settings*`, `.mcp.json`, + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`). If containment fails once, `.claude/settings*`, `.mcp.json`, repo instructions, or a skill become durable escalation targets for later higher-privilege runs. Autonomous mode uses an explicit tool roster, `strictMcpConfig`, and a read-only trusted instruction snapshot. @@ -359,7 +359,7 @@ Stated before the requirements because five of them derive from it. bounded reserve; when the reserve is spent, everything stops and one `fyi` is filed. - R44. **Cache stability requires an autonomous prompt mode.** The current prefix is assembled per turn from client environment, turn budget, and bridge tool availability - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-264`), and the SDK preset adds dynamic cwd/memory/git sections + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`), and the SDK preset adds dynamic cwd/memory/git sections unless `excludeDynamicSections: true`. Required: a fixed tool roster, dynamic sections excluded, deterministic trusted-instruction render (policy digest is v2), and an explicit static/dynamic boundary with every per-item value after it. Whether the provider honors cache reads across independent SDK diff --git a/docs/plans/async-collaboration.md b/docs/plans/async-collaboration.md index 1b4a0e3735..9c4e2b5612 100644 --- a/docs/plans/async-collaboration.md +++ b/docs/plans/async-collaboration.md @@ -79,7 +79,7 @@ is a security-critical build for both first-party backends (tool availability co environment, `strictMcpConfig`, real-subprocess containment testing), not a configuration change — the current backend auto-allows `Bash`, `Write`, `Edit`, `WebFetch`, `WebSearch`, and `Agent` (`export const DEFAULT_ALLOWED_TOOLS = [`, `packages/ui-backend-claude/src/tool-policy.ts:27-80`) and automatic SDK permission paths can bypass -`canUseTool`; the existing mandatory posture catches measured bypasses with hooks (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`). +`canUseTool`; the existing mandatory posture catches measured bypasses with hooks (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`). Because nothing ships until containment passes, **U14 and U15 are sequenced early** (see Sequencing below) rather than in unit order. Discovering a containment problem after the @@ -137,14 +137,14 @@ system gate. It is not a prerequisite to rebuild or a new independent package. - **Permission parking:** (`requestPermission: (req) => {`, `packages/ui-server/src/ws/bridge.ts:271-365`). Timeout unwind is (`abortController.abort();`, `packages/ui-server/src/ws/run-session.ts:359-365`). Durable escalation must checkpoint before unwinding; the existing ordinary bridge does not do that. -- **Tool enforcement:** (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`) closes measured bypasses. +- **Tool enforcement:** (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`) closes measured bypasses. (`export const DEFAULT_ALLOWED_TOOLS = [`, `packages/ui-backend-claude/src/tool-policy.ts:27-80`) is still a broad interactive roster. A shell-command classifier is not a process write/network boundary. - **Filtered environment and project settings:** (`export function envSnapshot(`, `packages/ui-backend-claude/src/config/env.ts:182-190`) and - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-264`). Restricted execution needs + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`). Restricted execution needs narrower credentials/configuration; it does not start from the old full-host-env assumption. - **Pi resources and extension gate:** (`export function createSessionResources(`, `packages/ui-backend-pi/src/session-resources.ts:29-144`) and - (`export function createPermissionGate(`, `packages/ui-backend-pi/src/permission-gate.ts:77-104`). Built-ins are disabled, + (`export function createPermissionGate(`, `packages/ui-backend-pi/src/permission-gate.ts:95-124`). Built-ins are disabled, but ambient resources/extensions and in-process execution still need containment. - **Cost timing:** (`store.rollupRun(runId);`, `packages/ui-server/src/activity/recorder.ts:597`) settles after execution; (`export function sumEffectiveCost(`, `packages/ui-server/src/activity/store.ts:177-192`) explicitly counts unpriced runs. @@ -1318,7 +1318,7 @@ exists to make, paid only when it would otherwise fail. **Files:** - Modify: `packages/ui-backend-claude/src/backend.ts` (prompt assembly at - (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-264`)) + (`export function createClaudeSdkTurn(`, `packages/ui-backend-claude/src/sdk-options.ts:85-263`)) - Test: `packages/ui-backend-claude/tests/autonomous-prompt.test.ts` **Approach:** a fixed tool roster, deterministic package-owned trusted instructions, diff --git a/docs/plans/policy-write-boundary.md b/docs/plans/policy-write-boundary.md index 50cc92c44d..bf88ab0324 100644 --- a/docs/plans/policy-write-boundary.md +++ b/docs/plans/policy-write-boundary.md @@ -24,7 +24,7 @@ remain unchanged. Approval is not production containment evidence. ## Why a tool hook or child-only wrapper is insufficient Claude assembles the ordinary SDK turn, project settings and in-process bridge -tools (`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-256`). +tools (`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-255`). The installed SDK exposes `spawnClaudeCodeProcess`; at the investigation baseline, the adapter wrapped the child with piped stdio ([historical `createWrappedSpawn`](https://github.com/schlessera/brain-kit/blob/ca1facc1e7086bd2119c260150e9f091e16817f1/packages/ui-backend-claude/src/spawn-wrapper.ts)). @@ -40,7 +40,7 @@ The same SDK construction is now worker-local (`async newSession`, initializes extensions (`createSessionResources`, `packages/ui-backend-pi/src/session-resources.ts:29-111`). The permission extension registers a tool-call handler (`createPermissionGate`, -`packages/ui-backend-pi/src/permission-gate.ts:77-99`); it cannot interpose arbitrary +`packages/ui-backend-pi/src/permission-gate.ts:95-119`); it cannot interpose arbitrary filesystem calls during extension initialization or execution. The experiment loads a real pi inline extension and observes its parent-side initialization changing the policy. Sandboxing only its spawned shell cannot repair that. diff --git a/packages/ui-backend-claude/src/ask-user-tool.ts b/packages/ui-backend-claude/src/ask-user-tool.ts index 11c12a8660..77f9ad1b6c 100644 --- a/packages/ui-backend-claude/src/ask-user-tool.ts +++ b/packages/ui-backend-claude/src/ask-user-tool.ts @@ -88,10 +88,12 @@ export { ASK_USER_DESCRIPTION, ASK_USER_INPUT_SCHEMA }; /** * The in-process MCP server for this backend's own tools. Registered under the * `brain-ui` name, so its tools are exposed to Claude as `mcp__brain-ui__*`. - * Tools are registered only when the host supplies the matching handler; - * `show_block` needs none, so it is always registered. + * Interactive callers select the full stable roster with unavailable-handler + * refusals; autonomous callers retain the exact handler-based membership. */ export function createBrainUiMcpServer(handlers: { + /** Interactive sessions expose the full fixed roster, including unavailable handlers. */ + stableRoster?: boolean; proposePolicyChange?: BackendBridge["proposePolicyChange"]; inbox?: Pick; application?: Pick; @@ -108,6 +110,19 @@ export function createBrainUiMcpServer(handlers: { /** Required alongside requestMask: the mask is written into this repo. */ brainPath?: string; }) { + // Missing handlers are executable refusals, never schema removals. The + // permission hook additionally refuses before SDK/settings auto-allow paths. + if (handlers.stableRoster) { + const unavailable = async () => { throw new Error("This tool has no available host handler in this turn."); }; + handlers = { ...handlers, + proposePolicyChange: handlers.proposePolicyChange ?? unavailable, + getLocation: handlers.getLocation ?? unavailable, + askUser: handlers.askUser ?? unavailable, askUserList: handlers.askUserList ?? unavailable, + askUserRank: handlers.askUserRank ?? unavailable, askUserForm: handlers.askUserForm ?? unavailable, + requestMask: handlers.requestMask ?? unavailable, queryActivity: handlers.queryActivity ?? unavailable, + inbox: handlers.inbox ?? {}, application: handlers.application ?? {}, + }; + } const tools: SdkMcpToolDefinition[] = [createShowBlockTool()]; if (handlers.proposePolicyChange) tools.push(createPolicyProposalTool(handlers.proposePolicyChange)); if (handlers.getLocation) tools.push(createLocationTool(handlers.getLocation)); @@ -146,7 +161,7 @@ export function createBrainUiMcpServer(handlers: { alwaysLoad: true, }); if (handlers.inbox) registerInboxTools(server.instance, handlers.inbox); - if (handlers.application?.applyBrain) registerBrainApplicationTools(server.instance, handlers.application); + if (handlers.application && (handlers.stableRoster || handlers.application.applyBrain)) registerBrainApplicationTools(server.instance, handlers.application, handlers.stableRoster); return server; } diff --git a/packages/ui-backend-claude/src/brain-application-tools.ts b/packages/ui-backend-claude/src/brain-application-tools.ts index a44349b138..d7e9fc3495 100644 --- a/packages/ui-backend-claude/src/brain-application-tools.ts +++ b/packages/ui-backend-claude/src/brain-application-tools.ts @@ -7,6 +7,7 @@ import { brainApplicationInput, brainReadBaseInput, brainReadBaseResult, BRAIN_A export function registerBrainApplicationTools( server: ReturnType["instance"], bridge: Pick, + stableRoster = false, ): void { for (const schema of brainApplicationInput.options) { const operation = schema.shape.operation.value; @@ -20,13 +21,14 @@ export function registerBrainApplicationTools( return { isError: !result.ok, content: [{ type: "text" as const, text: JSON.stringify(result) }] }; }); } - if (bridge.readBrainBase) server.registerTool("brain_read_base", { + if (stableRoster || bridge.readBrainBase) server.registerTool("brain_read_base", { description: BRAIN_READ_BASE_DESCRIPTION, inputSchema: brainReadBaseInput, _meta: { "anthropic/alwaysLoad": true }, }, async ({ path, ...range }) => { // The bridge returns the whole document and its server hash; only the // agent-facing result is paged, so no result outgrows the MCP budget. + if (!bridge.readBrainBase) return { isError: true, content: [{ type: "text" as const, text: "No server document-base reader is available." }] }; try { return { content: [{ type: "text" as const, text: JSON.stringify(brainReadBaseResult(await bridge.readBrainBase!(path), range)) }] }; } catch (error) { return { isError: true, content: [{ type: "text" as const, text: error instanceof Error ? error.message : "Read failed." }] }; } }); diff --git a/packages/ui-backend-claude/src/permission-hooks.ts b/packages/ui-backend-claude/src/permission-hooks.ts index a3a18fe31d..62b44859c6 100644 --- a/packages/ui-backend-claude/src/permission-hooks.ts +++ b/packages/ui-backend-claude/src/permission-hooks.ts @@ -1,3 +1,4 @@ +import { toolAvailabilityRefusal } from "./tool-availability.js"; import { createHash } from "node:crypto"; import { relative, resolve } from "node:path"; import { createWorkerFileObserver, NativeFileVersions, WORKER_AREA_ROOT, type ObservedWorkerFile } from "@schlessera/brain-ui-sdk/internal"; @@ -177,9 +178,15 @@ export function createPermissionWiring(options: { // An explicit "ask" beats all three — measured for each, and for the // settings hook with a canUseTool deny then holding — and it leaves another // hook's updatedInput intact, so the rewrites still reach the decision. - // Registered only under the declaration, so nothing moves for a turn that - // declares nothing. + // Allowlist escalation remains declaration-scoped. Interactive calls also + // receive unconditional availability refusals, including auto-allowed tools. const enforcementHook: HookCallback = async (hookInput) => { + if (hookInput.hook_event_name === "PreToolUse") { + const unavailable = toolAvailabilityRefusal(req, hookInput.tool_name); + if (unavailable) return { continue: true, hookSpecificOutput: { + hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: unavailable, + } }; + } if (hookInput.hook_event_name === "PreToolUse" && (req.signal.aborted || isCompletedAutonomousToolCall(req.autonomous, hookInput.tool_name, hookInput.tool_input))) { return { continue: true, hookSpecificOutput: { @@ -190,7 +197,7 @@ export function createPermissionWiring(options: { } if ( hookInput.hook_event_name !== "PreToolUse" || - allowed.has(hookInput.tool_name) + !enforced || allowed.has(hookInput.tool_name) ) { return { continue: true }; } @@ -254,6 +261,8 @@ export function createPermissionWiring(options: { ) => { if (req.signal.aborted || isCompletedAutonomousToolCall(req.autonomous, toolName, input)) return { behavior: "deny", message: "Cancelled or already completed autonomous call; no replay." }; + const unavailable = toolAvailabilityRefusal(req, toolName); + if (unavailable) return { behavior: "deny", message: unavailable }; const denied = denyCommand(toolName, input, opts.toolUseID); if (denied) return { behavior: "deny", message: denied }; if (routed(toolName)) return { behavior: "allow" }; @@ -342,6 +351,8 @@ export function createPermissionWiring(options: { if (hookInput.hook_event_name !== "PreToolUse") { return { continue: true }; } + const unavailable = toolAvailabilityRefusal(req, hookInput.tool_name); + if (unavailable) return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: unavailable } }; if (req.signal.aborted) return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Turn cancelled before tool admission." } }; const denied = denyCommand(hookInput.tool_name, hookInput.tool_input, hookInput.tool_use_id); if (denied) return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: denied } }; @@ -531,6 +542,7 @@ export function createPermissionWiring(options: { { matcher: "^Agent$", hooks: [agentHook] }, { matcher: "^Bash$", hooks: [rtkHook] }, ...(!req.autonomous ? [{ matcher: "^Read$", hooks: [beginNativeRead] }] : []), + ...(!enforced && !req.autonomous ? [{ hooks: [enforcementHook] }] : []), ], ...(!req.autonomous ? { PostToolUse: [{ matcher: "^(Read|Write|Edit|NotebookEdit)$", hooks: [finishNativeFile] }], diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index ee3869b6f5..7a9e09ef26 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -96,15 +96,15 @@ function assembleClaudeSdkTurn( envelope: ReturnType | undefined ): ClaudeSdkTurn { const { backend, req, profile, abortController, confirmPatterns, turnLock, log } = options; - // Only wire ask-user / location tools when the host bridge offers them. + // Bind current handlers; interactive schema presence never grants authority. const askUser = req.bridge.askUser; const getLocation = req.bridge.getLocation; // The mask editor is the one bridge tool that needs EYES: it opens an editor // and then blocks on a region someone has to paint. A turn that declared it - // has no grant surface has nobody to paint it, so the capability is withheld - // rather than offered and blocked on — and withheld at the append, because - // the append is what puts it inside the turn's allowlist whatever the - // posture declared (docs/decisions/voice-permission.md). Location, activity, + // has no grant surface has nobody to paint it, so its handler is withheld. + // The full interactive schema stays registered; mandatory call-time hooks + // refuse it before permission cards, including settings auto-allows. See + // docs/decisions/voice-permission.md. Location, activity, // show_block and ask_user ask nothing of a viewer, so they are unaffected. const requestMask = req.noGrantSurface === true ? undefined : req.bridge.requestMask; // The list card needs eyes for the same reason: up to thirty rows of chips @@ -223,11 +223,10 @@ function assembleClaudeSdkTurn( allowedTools: allowed, // The built-in AskUserQuestion picker needs a TTY; keep it disabled even // when no ask-user handler is present. - disallowedTools: ["AskUserQuestion", ...((hosted || req.autonomous) ? [ - "mcp__brain__brain_add", "mcp__brain__brain_update", "mcp__brain__brain_archive", - ] : []), ...(hosted && (sandbox.profile !== "governed" || req.posture === "voice") ? ["Write", "Edit", "NotebookEdit"] : [])], + disallowedTools: ["AskUserQuestion", + "mcp__brain__brain_add", "mcp__brain__brain_update", "mcp__brain__brain_archive"], ...createPermissionWiring({ - req, + req: { ...req, workerSandbox: sandbox }, allowedTools: allowed, confirmPatterns, brainPath: backend.brainPath, @@ -307,11 +306,12 @@ function assembleClaudeSdkTurn( }; } if (req.sessionId !== undefined) sdkOptions.resume = req.sessionId; - // The bridge tools are registered only when the bridge provides their - // handler, each together with its allowlist entry; `show_block` needs no - // handler, so the server itself is always present. + // Interactive schema membership is deployment-stable, even without a live + // handler. The mandatory hook refuses unavailable calls; fallback tool + // bodies also return model-readable errors instead of acquiring authority. sdkOptions.mcpServers = { "brain-ui": createBrainUiMcpServer({ + stableRoster: !req.autonomous, proposePolicyChange, application: req.bridge, askUser, diff --git a/packages/ui-backend-claude/src/tool-availability.ts b/packages/ui-backend-claude/src/tool-availability.ts new file mode 100644 index 0000000000..9f9afb64f9 --- /dev/null +++ b/packages/ui-backend-claude/src/tool-availability.ts @@ -0,0 +1,27 @@ +import type { StartTurnRequest } from "@schlessera/brain-ui-sdk/server"; + +const handlers: Record = { + ask_user: "askUser", ask_user_list: "askUserList", ask_user_rank: "askUserRank", + ask_user_form: "askUserForm", get_current_location: "getLocation", request_image_mask: "requestMask", + query_activity: "queryActivity", propose_policy_change: "proposePolicyChange", + inbox_list: "inboxList", inbox_get: "inboxGet", inbox_add: "inboxAdd", resolve_action: "resolveAction", + complete_share: "completeShare", brain_read_base: "readBrainBase", + brain_add: "applyBrain", brain_update: "applyBrain", brain_archive: "applyBrain", + write_file: "applyBrain", edit_file: "applyBrain", apply_staged_changes: "applyBrain", +}; +const visual = new Set(["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form"]); +const interactive = new Set(["propose_policy_change", "inbox_add", "resolve_action", "complete_share"]); + +/** Registration is stable; these live facts are authority only at call time. */ +export function toolAvailabilityRefusal(req: StartTurnRequest, toolName: string): string | undefined { + if (req.autonomous) return undefined; + const name = toolName.replace(/^mcp__brain-ui__/, ""); + if (["Write", "Edit", "NotebookEdit"].includes(name) + && (req.posture === "voice" || req.workerSandbox?.profile === "strict")) + return "Native writes are unavailable on strict or voice turns."; + if ((visual.has(name) || interactive.has(name)) && (req.noGrantSurface || req.posture === "voice")) + return `Tool "${name}" requires an interactive typed grant surface.`; + const handler = handlers[name]; + if (handler && typeof (req.bridge as unknown as Record)[handler] !== "function") + return `Tool "${name}" has no available host handler in this turn.`; +} diff --git a/packages/ui-backend-claude/tests/confirm-archive.test.ts b/packages/ui-backend-claude/tests/confirm-archive.test.ts index b82c312db9..2007568ee4 100644 --- a/packages/ui-backend-claude/tests/confirm-archive.test.ts +++ b/packages/ui-backend-claude/tests/confirm-archive.test.ts @@ -24,7 +24,11 @@ import type { } from "@schlessera/brain-ui-sdk/server"; import { createClaudeBackend } from "../src/backend"; -import { BRAIN_UPDATE_TOOL, MUTATING_TOOL_MATCHER } from "../src/tool-policy"; +import { MUTATING_TOOL_MATCHER } from "../src/tool-policy"; + +// This is the legacy MCP permission gate. Hosted applications own their +// exact operation approvals in the server; missing hosted routes fail closed. +const BRAIN_UPDATE_TOOL = "mcp__brain__brain_update"; const init = { type: "system", diff --git a/packages/ui-backend-claude/tests/edited-approval.test.ts b/packages/ui-backend-claude/tests/edited-approval.test.ts index 95eff1780b..d50d2a4169 100644 --- a/packages/ui-backend-claude/tests/edited-approval.test.ts +++ b/packages/ui-backend-claude/tests/edited-approval.test.ts @@ -33,7 +33,10 @@ import { createKeyedLock } from "@schlessera/brain-ui-sdk/server"; import { resetRtkProbe } from "../../ui-sdk/src/server/rtk.js"; import { createClaudeBackend } from "../src/backend"; -import { BRAIN_UPDATE_TOOL, MUTATING_TOOL_MATCHER } from "../src/tool-policy"; +import { MUTATING_TOOL_MATCHER } from "../src/tool-policy"; +// Exercise the retained legacy MCP confirmation gate; hosted operations use the server route. +const BRAIN_UPDATE_TOOL = "mcp__brain__brain_update"; + interface HookOutput { continue?: boolean; diff --git a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts new file mode 100644 index 0000000000..c3d6bf1856 --- /dev/null +++ b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts @@ -0,0 +1,89 @@ +/** Installed CLI/SDK, isolated by the caller's offline network namespace. */ +import { mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { BRIDGE_TOOL_POSTURE, createKeyedLock } from "@schlessera/brain-ui-sdk/server"; +import { createClaudeSdkTurn } from "../../src/sdk-options.js"; +import { createTurnLockBinding } from "../../src/turn-lock.js"; +import { createFixtureWorkerStartupView, prepareFixtureWorkerSandbox } from "../../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; + +const scratch = process.argv[2]!, brain = join(scratch, "brain"), home = join(scratch, "home"); +mkdirSync(join(brain, "notes"), { recursive: true }); mkdirSync(join(brain, ".claude")); mkdirSync(home); +writeFileSync(join(brain, "notes/raft.md"), "Odysseus repairs the raft.\n"); +writeFileSync(join(brain, "notes/loom.ipynb"), JSON.stringify({ cells: [{ cell_type: "code", id: "loom", source: ["Penelope weaves."], outputs: [], metadata: {}, execution_count: null }], metadata: {}, nbformat: 4, nbformat_minor: 5 })); +// A remembered/settings allow and a project hook cannot overrule a host deny. +writeFileSync(join(brain, ".claude/settings.json"), JSON.stringify({ permissions: { allow: ["Write", "Edit", "NotebookEdit", "mcp__brain-ui__*"] }, + hooks: { PreToolUse: [{ matcher: ".*", hooks: [{ type: "command", command: `printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'` }] }] } })); +const inputs: Record> = { + request_image_mask: { imagePath: "assets/raft.png" }, + ask_user_list: { prompt: "Choose a route", scale: [{ label: "Keep" }, { label: "Wait" }], items: [{ id: "raft", label: "Raft" }] }, + ask_user_rank: { prompt: "Rank routes", items: [{ id: "raft", label: "Raft" }, { id: "ship", label: "Ship" }] }, + ask_user_form: { prompt: "Plan route", nodes: [{ kind: "text", id: "route", prompt: "Which route?" }] }, + propose_policy_change: { content: "Odysseus policy", rationale: "Review route" }, + inbox_add: { text: "Repair raft", idempotencyKey: "raft" }, + resolve_action: { sessionId: "voyage", itemId: "raft", optionId: "keep", expectedVersion: 1 }, + complete_share: { receiptIds: ["raft"], files: [] }, + Write: { file_path: join(brain, "notes/raft.md"), content: "MUTATION" }, + Edit: { file_path: join(brain, "notes/raft.md"), old_string: "Odysseus", new_string: "MUTATION" }, + NotebookEdit: { notebook_path: join(brain, "notes/loom.ipynb"), cell_id: "loom", new_source: "MUTATION", edit_mode: "replace" }, +}; +const captures: { mode: string; tools: unknown; results: unknown[]; denials: string[]; calls: string[]; cards: number }[] = []; +let active: typeof captures[number], pending = true, pendingRead = true; +const names = Object.keys(inputs); +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + if (new URL(request.url).pathname !== "/v1/messages") return Response.json({}); + const body = await request.json() as any; + if (!body.tools?.some((tool: any) => tool.name === "Read")) return response([]); + active.tools ??= body.tools; + if (pendingRead) { pendingRead = false; return response([{ type: "tool_use", id: "toolu_loom_read", name: "Read", input: { file_path: join(brain, "notes/loom.ipynb") } }]); } + if (!pending) return response([]); + pending = false; + const selected = active.mode === "typed" ? ["ask_user_list"] : active.mode === "strict" ? names.filter(n => !n.startsWith("mcp") && /^[A-Z]/.test(n)) : active.mode === "voice" ? names : names.filter(n => !/^[A-Z]/.test(n)); + return response(selected.map((name, index) => ({ type: "tool_use", id: `toolu_raft_${captures.length}_${index}`, + name: /^[A-Z]/.test(name) ? name : `mcp__brain-ui__${name}`, input: inputs[name] }))); +} }); +function response(calls: unknown[]) { + const content: any[] = calls.length ? calls : [{ type: "text", text: "Odysseus checks the mast." }]; + const events: any[] = [{ type: "message_start", message: { id: "msg_raft", type: "message", role: "assistant", model: "claude-sonnet-4-6", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } }]; + content.forEach((block, index) => events.push({ type: "content_block_start", index, content_block: block.type === "tool_use" ? { ...block, input: {} } : { type: "text", text: "" } }, + { type: "content_block_delta", index, delta: block.type === "tool_use" ? { type: "input_json_delta", partial_json: JSON.stringify(block.input) } : { type: "text_delta", text: block.text } }, { type: "content_block_stop", index })); + events.push({ type: "message_delta", delta: { stop_reason: calls.length ? "tool_use" : "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, { type: "message_stop" }); + return new Response(events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }); +} +let sessionId: string | undefined; +try { + for (const mode of ["typed", "voice", "strict", "voice", "typed", "missing", "no-grant"]) { + if (captures.length === 3) sessionId = undefined; + active = { mode, tools: undefined, results: [], denials: [], calls: [], cards: 0 }; captures.push(active); pending = true; pendingRead = true; + const controller = new AbortController(); + const sandbox = prepareFixtureWorkerSandbox(brain, { home, profile: mode === "strict" ? "strict" : "governed", areas: [] }); + const view = createFixtureWorkerStartupView(brain); + const unavailable = (name: string) => async () => { active.calls.push(name); throw new Error("PRIVILEGED_HANDLER_EXECUTED"); }; + const bridge: any = { emit() {}, checkpointPermission() {}, async requestPermission() { active.cards++; return { behavior: "allow" }; } }; + if (mode !== "missing") for (const name of ["askUserList", "askUserRank", "askUserForm", "requestMask", "proposePolicyChange", "inboxList", "inboxGet", "inboxAdd", "resolveAction", "completeShare", "applyBrain", "readBrainBase"]) bridge[name] = unavailable(name); + if (mode === "typed") bridge.askUserList = async () => { active.calls.push("askUserList"); return { answers: { raft: "Keep" }, skipped: [] }; }; + const lock = createTurnLockBinding({ turn: { ended: false, pendingReleases: new Map() }, writeLock: createKeyedLock(), lockWaitMs: 1000, log() {} }); + const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { + prompt: "Check the voyage", sessionId, turnId: `voyage-${mode}`, signal: controller.signal, workerSandbox: sandbox, workerStartupView: view, + bridge, noGrantSurface: mode === "voice" || mode === "no-grant", ...(mode === "voice" ? { posture: "voice", enforceAllowedTools: true } : {}), + }, profile: { id: "fixture", label: "Fixture", model: "claude-sonnet-4-6", billing: "api", requiredEnvKeys: [], buildEnv: () => ({ + HOME: home, CLAUDE_CONFIG_DIR: join(home, ".claude"), ANTHROPIC_BASE_URL: server.url.origin, ANTHROPIC_API_KEY: "offline-odysseus", + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", + }) }, abortController: controller, allowedTools: ["Read", "Write", "Edit", "NotebookEdit", ...BRIDGE_TOOL_POSTURE.allowedTools("claude")], confirmPatterns: [], turnLock: lock, log() {} }); + turn.options.hooks!.PreToolUse = turn.options.hooks!.PreToolUse!.map(group => ({ ...group, hooks: group.hooks.map(hook => async (...args) => { + const result = await hook(...args); + if ("hookSpecificOutput" in result && result.hookSpecificOutput?.hookEventName === "PreToolUse" && result.hookSpecificOutput.permissionDecision === "deny" && args[0].hook_event_name === "PreToolUse") active.denials.push(args[0].tool_name); + return result; + }) })); + const timer = setTimeout(() => controller.abort(), 30_000); + try { + for await (const message of query({ prompt: turn.prompt, options: turn.options })) { + if (message.type === "user" && Array.isArray(message.message.content)) for (const block of message.message.content) if (block.type === "tool_result") { if (block.tool_use_id !== "toolu_loom_read") active.results.push(block); lock.releaseForTool(block.tool_use_id); } + if (message.type === "result") { if (message.subtype !== "success") throw new Error(JSON.stringify(message)); sessionId = message.session_id; } + } + if (!sessionId) throw new Error("Missing runtime session"); + Object.assign(active, { sessionId }); + } finally { clearTimeout(timer); await turn.finishWorker(); lock.close(); view.cleanup(); } + } + writeFileSync(join(scratch, "requests.json"), JSON.stringify({ captures, raft: readFileSync(join(brain, "notes/raft.md"), "utf8"), loom: readFileSync(join(brain, "notes/loom.ipynb"), "utf8") })); +} finally { server.stop(true); } diff --git a/packages/ui-backend-claude/tests/no-grant-surface.test.ts b/packages/ui-backend-claude/tests/no-grant-surface.test.ts index 85ca347f55..827652abdd 100644 --- a/packages/ui-backend-claude/tests/no-grant-surface.test.ts +++ b/packages/ui-backend-claude/tests/no-grant-surface.test.ts @@ -37,10 +37,13 @@ import type { import { createClaudeBackend } from "../src/backend"; import { MASK_TOOL_NAME } from "../src/mask-tool"; import { ASK_USER_LIST_TOOL_NAME } from "../src/ask-user-list-tool"; -import { BRAIN_UPDATE_TOOL } from "../src/tool-policy"; + import { runToolCall } from "./helpers/run-tool-call"; // Hooks belong to a live turn. End every fixture after its tool assertions. +// Exercise the retained legacy MCP confirmation gate; hosted operations use the server route. +const BRAIN_UPDATE_TOOL = "mcp__brain__brain_update"; + const finishTurns: (() => Promise)[] = []; afterEach(async () => { for (const finish of finishTurns.splice(0)) await finish(); diff --git a/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts b/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts index 77408e7302..09088f697b 100644 --- a/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts +++ b/packages/ui-backend-claude/tests/sdk-options-mcp.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import * as worker from "@schlessera/brain-ui-sdk/internal"; -import { BRIDGE_TOOL_POSTURE } from "@schlessera/brain-ui-sdk/server"; +import { BRAIN_APPLICATION_TOOLS, BRIDGE_TOOL_POSTURE } from "@schlessera/brain-ui-sdk/server"; import { createClaudeSdkTurn } from "../src/sdk-options.js"; @@ -76,7 +76,7 @@ describe("the SDK options a turn is built with", () => { // The full roster, because a handler that silently failed to register // would leave a tool deferred by absence rather than by policy. expect(tools.map((tool) => tool.name).sort()).toEqual( - [...BRIDGE_TOOL_POSTURE.names].sort() + [...BRIDGE_TOOL_POSTURE.names, "propose_policy_change", "brain_read_base", ...Object.values(BRAIN_APPLICATION_TOOLS)].sort() ); for (const tool of tools) { expect(tool._meta?.["anthropic/alwaysLoad"]).toBe(true); @@ -100,14 +100,14 @@ describe("the SDK options a turn is built with", () => { await (turn.options.mcpServers!["brain-ui"] as { instance: { connect(t: unknown): Promise } }).instance.connect(serverTransport); await client.connect(clientTransport); const names = (await client.listTools()).tools.map(tool => tool.name); - expect(names.includes("propose_policy_change")).toBe(mode === "interactive"); + expect(names.includes("propose_policy_change")).toBe(mode !== "autonomous"); expect(turn.options.allowedTools).not.toContain("mcp__brain-ui__propose_policy_change"); expect(JSON.stringify(turn.options.systemPrompt).includes("propose_policy_change")).toBe(mode === "interactive"); await client.close(); } finally { await finish?.(); rmSync(brain, { recursive: true, force: true }); } }); - test("a no-grant turn withholds the form schema, allowlist entry and prompt brief", async () => { + test("a no-grant turn retains the form schema while withholding authority", async () => { const brain = mkdtempSync(join(tmpdir(), "sdk-options-form-")); try { const { options } = turnFor(brain, { askUserForm: async () => ({ answers: {} }) }, true); @@ -121,7 +121,7 @@ describe("the SDK options a turn is built with", () => { await client.connect(clientTransport); const { tools } = await client.listTools(); expect(tools.length).toBeGreaterThan(0); - expect(tools.map((tool) => tool.name)).not.toContain("ask_user_form"); + expect(tools.map((tool) => tool.name)).toContain("ask_user_form"); expect(options.allowedTools).not.toContain("mcp__brain-ui__ask_user_form"); expect(JSON.stringify(options.systemPrompt)).not.toContain("ask_user_form"); await client.close(); diff --git a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts new file mode 100644 index 0000000000..0d6e21e0ea --- /dev/null +++ b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +test("installed Claude keeps request tools fixed and refuses every unavailable tool before settings, cards and handlers", async () => { + const root = resolve(import.meta.dir, "../../.."), scratch = mkdtempSync(join(tmpdir(), "odysseus-roster-runtime-")); + const child = Bun.spawn(["bwrap", "--unshare-net", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", process.execPath, + "--preload", join(root, "scripts/test-network-child-preload.ts"), join(import.meta.dir, "fixtures/stable-roster-probe.ts"), scratch], + { cwd: root, env: { PATH: process.env.PATH!, LANG: "C.UTF-8", TMPDIR: tmpdir() }, stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => child.kill(), 170_000); + try { + const [out, err, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + expect(exit, err || out).toBe(0); + const { captures, raft, loom } = JSON.parse(readFileSync(join(scratch, "requests.json"), "utf8")); + const bridgeNames = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"].map(name => `mcp__brain-ui__${name}`); + const natives = ["Write", "Edit", "NotebookEdit"]; + expect(captures).toHaveLength(7); + expect(new Set(captures.slice(0, 3).map((c: any) => c.sessionId)).size).toBe(1); + expect(new Set(captures.slice(3).map((c: any) => c.sessionId)).size).toBe(1); + expect(captures[0].sessionId).not.toBe(captures[3].sessionId); + for (const capture of captures) { + expect(JSON.stringify(capture.tools), `BYTE_IDENTICAL_PROVIDER_TOOLS_${capture.mode}`).toBe(JSON.stringify(captures[0].tools)); + expect(capture.cards, `NO_PERMISSION_CARDS_${capture.mode}`).toBe(0); + if (capture.mode === "typed") { expect(capture.calls).toEqual(["askUserList"]); expect(capture.results[0].is_error).not.toBe(true); continue; } + const expected = capture.mode === "strict" ? natives : capture.mode === "voice" ? [...bridgeNames, ...natives] : bridgeNames; + for (const name of expected) expect(capture.denials, `RUNTIME_HOOK_REFUSAL_${capture.mode}_${name}: ${JSON.stringify(capture.results)}`).toContain(name); + expect(capture.calls, `NO_PRIVILEGED_HANDLER_${capture.mode}`).toEqual([]); + expect(capture.results.length).toBe(capture.mode === "strict" ? 3 : capture.mode === "voice" ? 11 : 8); + expect(capture.results.every((r: any) => r.is_error === true), `MODEL_READABLE_REFUSALS_${capture.mode}`).toBe(true); + } + expect(raft).toBe("Odysseus repairs the raft.\n"); expect(loom).not.toContain("MUTATION"); + } finally { clearTimeout(timer); child.kill(); rmSync(scratch, { recursive: true, force: true }); } +}, 180_000); diff --git a/packages/ui-backend-claude/tests/stable-roster.test.ts b/packages/ui-backend-claude/tests/stable-roster.test.ts new file mode 100644 index 0000000000..cdb93e8318 --- /dev/null +++ b/packages/ui-backend-claude/tests/stable-roster.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { createClaudeSdkTurn } from "../src/sdk-options.js"; +import { createPermissionWiring } from "../src/permission-hooks.js"; + +const refused = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"]; +const bridge = () => Object.fromEntries(["askUser", "askUserList", "askUserRank", "askUserForm", "requestMask", "proposePolicyChange", "inboxList", "inboxGet", "inboxAdd", "resolveAction", "completeShare", "queryActivity", "getLocation", "applyBrain", "readBrainBase"].map(name => [name, async () => { throw new Error("PRIVILEGED_HANDLER_EXECUTED"); }])); + +test("Claude complete tool schemas and native exclusions stay identical voice-first, typed, missing-handler and strict", async () => { + const brain = mkdtempSync(join(tmpdir(), "odysseus-roster-")); + const rosters: string[] = [], exclusions: string[] = []; + try { + for (const mode of ["voice", "typed", "missing", "strict", "no-grant"] as const) { + const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { + prompt: "Return to Ithaca", signal: new AbortController().signal, + bridge: (mode === "missing" ? {} : bridge()) as never, + ...(mode === "voice" ? { posture: "voice", enforceAllowedTools: true } : {}), + noGrantSurface: mode === "voice" || mode === "no-grant", + workerSandbox: { brain, profile: mode === "strict" ? "strict" : "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, + }, profile: { requiredEnvKeys: [], buildEnv: () => ({}) } as never, + abortController: new AbortController(), allowedTools: [], confirmPatterns: [], + turnLock: { acquireForTool: async () => ({ ok: true }), releaseForTool() {} } as never, log() {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "roster", version: "1" }); + try { + await (turn.options.mcpServers!["brain-ui"] as any).instance.connect(st); + await client.connect(ct); + const tools = (await client.listTools()).tools; + for (const name of [...refused, "brain_read_base", "brain_add", "show_block", "turn_temp"]) + expect(tools.some(tool => tool.name === name), `FULL_ROSTER_${mode}_${name}`).toBe(true); + rosters.push(JSON.stringify(tools)); exclusions.push(JSON.stringify(turn.options.disallowedTools)); + } finally { await client.close(); await turn.finishWorker(); } + } + for (const roster of rosters.slice(1)) expect(roster, "BYTE_IDENTICAL_TOOL_SCHEMAS").toBe(rosters[0]); + for (const exclusion of exclusions.slice(1)) expect(exclusion, "BYTE_IDENTICAL_NATIVE_EXCLUSIONS").toBe(exclusions[0]); + } finally { rmSync(brain, { recursive: true, force: true }); } +}); + +for (const mode of ["voice", "no-grant", "missing"] as const) for (const name of refused) test(`Claude ${mode} refuses ${name} before approval or handler`, async () => { + let cards = 0; + const req = { prompt: "Return to Ithaca", signal: new AbortController().signal, + bridge: { ...(mode === "missing" ? {} : bridge()), requestPermission: async () => { cards++; return { behavior: "allow" }; } }, + ...(mode === "voice" ? { posture: "voice" } : {}), noGrantSurface: mode === "no-grant", + }; + const wiring = createPermissionWiring({ req: req as never, allowedTools: [`mcp__brain-ui__${name}`], brainPath: "/tmp/odysseus", confirmPatterns: [], + turnLock: { releaseForTool() {} } as never, childEnv: {}, log() {} }); + const hook = wiring.hooks!.PreToolUse!.find(group => !group.matcher)!.hooks[0]!; + const output = await hook({ hook_event_name: "PreToolUse", tool_name: `mcp__brain-ui__${name}`, tool_input: {}, tool_use_id: "loom" } as never, undefined, { signal: req.signal }); + expect(output, `HOOK_REFUSAL_${mode}_${name}`).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } }); + expect(await wiring.canUseTool!(`mcp__brain-ui__${name}`, {}, { toolUseID: "loom" } as never)).toMatchObject({ behavior: "deny" }); + expect(cards).toBe(0); +}); +for (const mode of ["voice", "strict"] as const) for (const name of ["Write", "Edit", "NotebookEdit"]) test(`Claude ${mode} denies registered native ${name}`, async () => { + const req = { signal: new AbortController().signal, bridge: { requestPermission: async () => { throw new Error("CARD_OPENED"); } }, + workerSandbox: { profile: mode === "strict" ? "strict" : "governed" }, ...(mode === "voice" ? { posture: "voice" } : {}) }; + const wiring = createPermissionWiring({ req: req as never, allowedTools: [name], brainPath: "/tmp/odysseus", confirmPatterns: [], + turnLock: { releaseForTool() {} } as never, childEnv: {}, log() {} }); + const hook = wiring.hooks!.PreToolUse!.find(group => !group.matcher)!.hooks[0]!; + expect(await hook({ hook_event_name: "PreToolUse", tool_name: name, tool_input: { file_path: "notes/raft.md" }, tool_use_id: "loom" } as never, + undefined, { signal: req.signal })).toMatchObject({ hookSpecificOutput: { permissionDecision: "deny" } }); + expect(await wiring.canUseTool!(name, {}, { toolUseID: "loom" } as never)).toMatchObject({ behavior: "deny" }); +}); diff --git a/packages/ui-backend-claude/tests/voice-posture.test.ts b/packages/ui-backend-claude/tests/voice-posture.test.ts index b5e7d8e37e..c69e88a955 100644 --- a/packages/ui-backend-claude/tests/voice-posture.test.ts +++ b/packages/ui-backend-claude/tests/voice-posture.test.ts @@ -303,7 +303,7 @@ describe("a turn under the voice posture", () => { ]); }); - test("allows brain_add and brain_update in that same turn: read-mostly, not read-only", async () => { + test("missing application handlers refuse brain_add and brain_update without cards", async () => { const turn = await duringTurn([ call("mcp__brain-ui__brain_add", { type: "note", title: "Garden" }, "add-1"), call( @@ -315,8 +315,10 @@ describe("a turn under the voice posture", () => { ]); const [add, update, bash] = turn.value; - expect(add!.executed).toBe(true); - expect(update!.executed).toBe(true); + expect(add!.executed).toBe(false); + expect(update!.executed).toBe(false); + expect(add!.message).toContain("host handler"); + expect(update!.message).toContain("host handler"); expect(bash!.executed).toBe(false); expect(turn.requests).toHaveLength(0); expect(approvalFrames(turn.frames)).toHaveLength(0); diff --git a/packages/ui-backend-pi/src/backend-options.ts b/packages/ui-backend-pi/src/backend-options.ts index 2417d7bc86..6b5f4fe085 100644 --- a/packages/ui-backend-pi/src/backend-options.ts +++ b/packages/ui-backend-pi/src/backend-options.ts @@ -207,3 +207,9 @@ export function capsOf(bridge: BackendBridge): SessionCaps { mask: Boolean(bridge.requestMask), }; } + +/** Deployment-owned tools exist even when this turn has no matching handler. */ +export const INTERACTIVE_SESSION_CAPS: Readonly = Object.freeze({ + policyChange: true, askUser: true, askUserList: true, askUserRank: true, askUserForm: true, + location: true, activity: true, inbox: true, mask: true, turnTemp: true, +}); diff --git a/packages/ui-backend-pi/src/bridge-tools.ts b/packages/ui-backend-pi/src/bridge-tools.ts index 1a15b63079..4261ce71fb 100644 --- a/packages/ui-backend-pi/src/bridge-tools.ts +++ b/packages/ui-backend-pi/src/bridge-tools.ts @@ -304,7 +304,7 @@ export function createPiBridgeTools(options: PiBridgeToolOptions): ToolDefinitio if (capabilities?.policyChange) tools.push({ name: PROPOSE_POLICY_CHANGE_CONTRACT.name, label: "Propose policy change", description: PROPOSE_POLICY_CHANGE_CONTRACT.description, parameters: toPiParameters(PROPOSE_POLICY_CHANGE_INPUT_SCHEMA), async execute(id, raw) { - if (turn.noGrantSurface || turn.autonomous || !turn.bridge?.proposePolicyChange) throw new Error("Policy proposals are unavailable in this turn."); + if (turn.noGrantSurface || turn.posture === "voice" || turn.autonomous || !turn.bridge?.proposePolicyChange) throw new Error("Policy proposals are unavailable in this turn."); const result = await turn.bridge.proposePolicyChange(id, PROPOSE_POLICY_CHANGE_INPUT_SCHEMA.parse(raw)); return textResult(JSON.stringify(result), result); }, diff --git a/packages/ui-backend-pi/src/permission-gate.ts b/packages/ui-backend-pi/src/permission-gate.ts index 47e0209f9d..d73eb14834 100644 --- a/packages/ui-backend-pi/src/permission-gate.ts +++ b/packages/ui-backend-pi/src/permission-gate.ts @@ -55,6 +55,24 @@ export interface PermissionGateOptions { confirmPatterns: readonly RegExp[]; } +/** Registered bridge tools refuse before remembered grants or permission cards. */ +export function piToolAvailabilityRefusal(turn: TurnContext, name: string): string | undefined { + if (turn.autonomous) return undefined; + const visual = ["ask_user_list", "ask_user_rank", "ask_user_form", "request_image_mask"]; + const interactive = ["propose_policy_change", "inbox_add", "resolve_action", "complete_share"]; + if ([...visual, ...interactive].includes(name) && (turn.noGrantSurface || turn.posture === "voice")) + return `Tool "${name}" requires an interactive typed grant surface.`; + const handlers: Record = { + ask_user: "askUser", ask_user_list: "askUserList", ask_user_rank: "askUserRank", ask_user_form: "askUserForm", + request_image_mask: "requestMask", get_current_location: "getLocation", query_activity: "queryActivity", + propose_policy_change: "proposePolicyChange", inbox_list: "inboxList", inbox_get: "inboxGet", + inbox_add: "inboxAdd", resolve_action: "resolveAction", complete_share: "completeShare", brain_read_base: "readBrainBase", + }; + const handler = handlers[name]; + if (handler && typeof (turn.bridge as unknown as Record | null)?.[handler] !== "function") + return `Tool "${name}" has no available host handler in this turn.`; +} + /** Why a tool call needs approval, or null when it can run immediately. */ export function approvalReason( toolName: string, @@ -83,6 +101,8 @@ export function createPermissionGate(options: PermissionGateOptions): InlineExte if (turn.signal?.aborted) return { block: true, reason: "Turn cancelled before tool admission." }; if (isCompletedAutonomousToolCall(turn.autonomous, event.toolName, event.input)) return { block: true, reason: "This call already completed before yielding; inspect its retained result instead of replaying it." }; + const unavailable = piToolAvailabilityRefusal(turn, event.toolName); + if (unavailable) return { block: true, reason: unavailable }; // The server route rechecks the exact operation, live principal, // membership and approval. Avoid a second runtime approval card. if (turn.bridge?.applyBrain && ["brain_add", "brain_update", "brain_archive", "write_file", "edit_file", "apply_staged_changes"].includes(event.toolName)) return undefined; @@ -110,6 +130,8 @@ export function createPermissionGate(options: PermissionGateOptions): InlineExte export async function decidePiToolPermission(options: PermissionGateOptions, toolName: string, input: Record, toolUseId: string): Promise { const { turn, allowedTools } = options; + const unavailable = piToolAvailabilityRefusal(turn, toolName); + if (unavailable) return { behavior: "deny", message: unavailable }; const policy = compileWorkerCommandPolicy(turn.autonomous ? undefined : turn.commandPolicy); const outward = (value: unknown) => prepareOutwardPlan({ toolName, shellToolName: "bash", input: value, allow: turn.commandPolicy?.outwardAllow, autonomous: Boolean(turn.autonomous), voice: turn.posture === "voice", configurationRoot: turn.configurationRoot, maintenance: turn.maintenance }); diff --git a/packages/ui-backend-pi/src/session-pool.ts b/packages/ui-backend-pi/src/session-pool.ts index be89bd4d10..25d3acd8a8 100644 --- a/packages/ui-backend-pi/src/session-pool.ts +++ b/packages/ui-backend-pi/src/session-pool.ts @@ -1,7 +1,7 @@ import type { StartTurnRequest } from "@schlessera/brain-ui-sdk/server"; import { BackendBusyError } from "@schlessera/brain-ui-sdk/server"; -import { capsOf, type PiSessionLike, type SessionEnv } from "./backend-options.js"; +import { INTERACTIVE_SESSION_CAPS, capsOf, type PiSessionLike, type SessionEnv } from "./backend-options.js"; import type { SessionRuntime } from "./session-runtime.js"; import type { TurnContext } from "./turn-context.js"; @@ -53,7 +53,7 @@ export function createSessionPool(runtime: SessionRuntime, retainSessions = true ...(req.workerLock ? { workerLock: req.workerLock } : {}), ...(req.autonomous ? { autonomous: req.autonomous } : {}), ...(req.client ? { client: req.client } : {}), - caps: { ...capsOf(req.bridge), policyChange: !req.autonomous && !req.noGrantSurface && req.posture !== "voice" && Boolean(req.bridge.proposePolicyChange), turnTemp: !req.autonomous }, + caps: req.autonomous ? { ...capsOf(req.bridge), policyChange: false, turnTemp: false } : { ...INTERACTIVE_SESSION_CAPS }, ...(req.turnBudgetMs ? { turnBudgetMs: req.turnBudgetMs } : {}), }; if (req.sessionId) { diff --git a/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts b/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts new file mode 100644 index 0000000000..2201048e1c --- /dev/null +++ b/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts @@ -0,0 +1,64 @@ +/** Real Pi worker, local Responses endpoint, no reachable provider. */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createPiBackend } from "../../src/backend.js"; +import { prepareFixtureWorkerSandbox } from "../../../ui-sdk/tests/helpers/worker-startup-view-fixture.js"; +import { startFixtureTurn } from "../../../../tests/fixtures/worker-input-fixture.js"; +import type { ServerMessage } from "@schlessera/brain-ui-sdk/server"; +const scratch = process.argv[2]!, brain = join(scratch, "brain"); +mkdirSync(join(brain, "notes"), { recursive: true }); writeFileSync(join(brain, "notes/raft.md"), "Odysseus repairs the raft."); +const inputs: Record = { + request_image_mask: { imagePath: "assets/raft.png" }, + ask_user_list: { prompt: "Choose route", scale: [{ label: "Keep" }, { label: "Wait" }], items: [{ id: "raft", label: "Raft" }] }, + ask_user_rank: { prompt: "Rank routes", items: [{ id: "raft", label: "Raft" }, { id: "ship", label: "Ship" }] }, + ask_user_form: { prompt: "Plan route", nodes: [{ kind: "text", id: "route", prompt: "Which route?" }] }, + propose_policy_change: { content: "Odysseus policy", rationale: "Review route" }, + inbox_add: { text: "Repair raft", idempotencyKey: "raft" }, + resolve_action: { sessionId: "voyage", itemId: "raft", optionId: "keep", expectedVersion: 1 }, + complete_share: { receiptIds: ["raft"], files: [] }, +}; +const captures: any[] = []; let active: any, next = 0; +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + if (new URL(request.url).pathname !== "/v1/responses") throw new Error("Unexpected endpoint"); + const body = await request.json() as any; active.tools ??= body.tools; + const name = active.mode === "typed" ? (next === 0 ? "ask_user_list" : undefined) : Object.keys(inputs)[next]; + next++; + const id = `raft_${next}`, answer: any = name ? { type: "function_call", id: `fc_${id}`, call_id: `call_${id}`, name, arguments: JSON.stringify(inputs[name]) } + : { type: "message", role: "assistant", content: [{ type: "output_text", text: "Odysseus checks the mast.", annotations: [] }], status: "completed", id }; + const response = { id: `resp_${id}`, object: "response", status: "completed", model: "gpt-4o-mini", output: [answer], usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } }; + const events = [{ type: "response.created", response: { ...response, status: "in_progress", output: [] } }, + { type: "response.output_item.added", output_index: 0, item: name ? { ...answer, arguments: "" } : { ...answer, content: [] } }, + ...(name ? [{ type: "response.function_call_arguments.delta", item_id: `fc_${id}`, output_index: 0, delta: answer.arguments }] : [ + { type: "response.content_part.added", item_id: id, output_index: 0, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, + { type: "response.output_text.delta", item_id: id, output_index: 0, content_index: 0, delta: answer.content[0].text }]), + { type: "response.output_item.done", output_index: 0, item: answer }, { type: "response.completed", response }]; + return new Response(events.map(e => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }); +} }); +mkdirSync(process.env.PI_CODING_AGENT_DIR!, { recursive: true }); +writeFileSync(join(process.env.PI_CODING_AGENT_DIR!, "models.json"), JSON.stringify({ providers: { openai: { baseUrl: `${server.url.origin}/v1`, apiKey: "offline-odysseus" } } })); +const backend = createPiBackend({ brainPath: brain, sessionDir: join(scratch, "sessions"), loadExtensions: false, + profiles: [{ id: "fixture", label: "Fixture", vendor: "openai", model: "gpt-4o-mini", thinkingLevel: "off" }], confirmBashPatterns: [] }); +let sessionId: string | undefined; +try { + for (const mode of ["no-grant", "typed", "missing"]) { + active = { mode, tools: undefined, frames: [], calls: [], cards: 0 }; captures.push(active); next = 0; + const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 30_000); + const bridge: any = { emit(frame: ServerMessage) { active.frames.push(frame); }, async requestPermission() { active.cards++; return { behavior: "allow" }; } }; + if (mode !== "missing") for (const name of ["askUserList", "askUserRank", "askUserForm", "requestMask", "proposePolicyChange", "inboxList", "inboxAdd", "resolveAction", "completeShare"]) + bridge[name] = async () => { active.calls.push(name); throw new Error("PRIVILEGED_HANDLER_EXECUTED"); }; + if (mode === "typed") bridge.askUserList = async () => { active.calls.push("askUserList"); return { answers: { raft: "Keep" }, skipped: [] }; }; + try { + await startFixtureTurn(backend, brain, { prompt: "Check voyage", profileId: "fixture", sessionId, signal: controller.signal, + turnId: `voyage-${mode}`, noGrantSurface: mode === "no-grant", enforceAllowedTools: mode === "no-grant", workerSandbox: prepareFixtureWorkerSandbox(brain, { profile: "strict", areas: [] }), bridge }); + const result = active.frames.findLast((frame: any) => frame.type === "result"); + if (!result || result.outcome !== "success") throw new Error(JSON.stringify(active.frames)); + sessionId = result.sessionId; + } finally { clearTimeout(timer); } + } + // Existing production boundary: no provider request is emitted for voice. + const before = captures.length; let voiceRefused = false; + try { await backend.startTurn({ prompt: "Check voyage", sessionId, signal: new AbortController().signal, posture: "voice", noGrantSurface: true, enforceAllowedTools: true, + bridge: { emit() {}, async requestPermission() { throw new Error("VOICE_CARD"); } } }); } + catch (error) { voiceRefused = String(error).includes("no voice tool posture"); } + writeFileSync(join(scratch, "requests.json"), JSON.stringify({ captures, voiceRefused, unchanged: before === captures.length })); +} finally { server.stop(true); } diff --git a/packages/ui-backend-pi/tests/policy-membership.test.ts b/packages/ui-backend-pi/tests/policy-membership.test.ts index 2a36b27fed..893e4d9e11 100644 --- a/packages/ui-backend-pi/tests/policy-membership.test.ts +++ b/packages/ui-backend-pi/tests/policy-membership.test.ts @@ -15,6 +15,6 @@ for (const mode of ["interactive", "voice", "autonomous"] as const) test(`pi pol ...(mode === "autonomous" ? { autonomous: { allowedTools: [], systemPromptAppend: "Autonomous." } as never } : {}) }; const { entry } = await pool.acquire(req); const names = createPiBridgeTools({ brainPath: ".", turn: entry.turnContext, capabilities: observed!.caps }).map(tool => tool.name); - expect(names.includes("propose_policy_change")).toBe(mode === "interactive"); + expect(names.includes("propose_policy_change")).toBe(mode !== "autonomous"); expect(DEFAULT_PI_ALLOWED_TOOLS).not.toContain("propose_policy_change"); pool.finish(entry); }); diff --git a/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts b/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts new file mode 100644 index 0000000000..0db7ab023e --- /dev/null +++ b/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +test("Pi native workers retain full schemas across no-grant, typed and missing-handler turns without authority widening", async () => { + const root = resolve(import.meta.dir, "../../.."), scratch = mkdtempSync(join(tmpdir(), "odysseus-pi-roster-")); + const child = Bun.spawn(["bwrap", "--unshare-net", "--bind", "/", "/", "--proc", "/proc", "--dev", "/dev", process.execPath, + "--preload", join(root, "scripts/test-network-child-preload.ts"), join(import.meta.dir, "fixtures/stable-roster-probe.ts"), scratch], + { cwd: root, env: { PATH: process.env.PATH!, LANG: "C.UTF-8", TMPDIR: tmpdir(), HOME: join(scratch, "home"), PI_CODING_AGENT_DIR: join(scratch, "pi-agent") }, stdout: "pipe", stderr: "pipe" }); + const timer = setTimeout(() => child.kill(), 170_000); + try { + const [out, err, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); expect(exit, err || out).toBe(0); + const { captures, voiceRefused, unchanged } = JSON.parse(readFileSync(join(scratch, "requests.json"), "utf8")); + const names = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"]; + for (const capture of captures) { + expect(JSON.stringify(capture.tools), `PI_BYTE_IDENTICAL_PROVIDER_TOOLS_${capture.mode}`).toBe(JSON.stringify(captures[0].tools)); + expect(capture.cards).toBe(0); + const results = capture.frames.filter((frame: any) => frame.type === "tool_result"); + if (capture.mode === "typed") { expect(capture.calls).toEqual(["askUserList"]); expect(results[0].isError).not.toBe(true); continue; } + expect(capture.calls).toEqual([]); expect(results.length).toBe(names.length); + for (const result of results) expect(result.isError, `PI_MODEL_READABLE_REFUSAL_${capture.mode}_${result.toolUseId}`).toBe(true); + } + expect(voiceRefused).toBe(true); expect(unchanged).toBe(true); + } finally { clearTimeout(timer); child.kill(); rmSync(scratch, { recursive: true, force: true }); } +}, 180_000); diff --git a/packages/ui-backend-pi/tests/stable-roster.test.ts b/packages/ui-backend-pi/tests/stable-roster.test.ts new file mode 100644 index 0000000000..5c8c6c4589 --- /dev/null +++ b/packages/ui-backend-pi/tests/stable-roster.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { createSessionPool } from "../src/session-pool.js"; +import { createPiBridgeTools } from "../src/bridge-tools.js"; +import { createTurnContext } from "../src/turn-context.js"; +import { decidePiToolPermission } from "../src/permission-gate.js"; +import type { SessionEnv } from "../src/backend-options.js"; + +const names = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"]; +test("Pi production session acquisition builds one full roster even voice-first with no handlers", async () => { + const rosters: string[] = []; + for (const mode of ["voice", "typed", "missing", "no-grant"]) { + let observed: SessionEnv | undefined; + const make = async (_id: unknown, env: SessionEnv) => { observed = env; return { session: { sessionId: "voyage", dispose() {} } as never, turnContext: createTurnContext() }; }; + const pool = createSessionPool({ newSession: make, openSession: make } as never, false); + const bridge: any = { emit() {}, requestPermission: async () => ({ behavior: "allow" }) }; + if (mode === "typed") bridge.proposePolicyChange = async () => {}; + const { entry } = await pool.acquire({ prompt: "Return to Ithaca", signal: new AbortController().signal, bridge, + ...(mode === "voice" ? { posture: "voice" } : {}), noGrantSurface: mode !== "typed" }); + const tools = createPiBridgeTools({ brainPath: "/tmp/odysseus", turn: entry.turnContext, capabilities: observed!.caps }); + rosters.push(JSON.stringify(tools.map(tool => ({ name: tool.name, description: tool.description, parameters: tool.parameters })))); + for (const name of names) expect(tools.some(tool => tool.name === name), `PI_FULL_ROSTER_${mode}_${name}`).toBe(true); + pool.finish(entry); + } + for (const roster of rosters.slice(1)) expect(roster, "PI_FIXED_VOICE_FIRST_ROSTER").toBe(rosters[0]); +}); +for (const mode of ["voice", "no-grant", "missing"]) for (const name of names) test(`Pi ${mode} gate refuses ${name} without approval`, async () => { + const turn = createTurnContext(); let cards = 0; + const bridge: any = { emit() {}, requestPermission: async () => { cards++; return { behavior: "allow" }; } }; + if (mode !== "missing") for (const handler of ["requestMask", "askUserList", "askUserRank", "askUserForm", "proposePolicyChange", "inboxAdd", "resolveAction", "completeShare"]) + bridge[handler] = async () => { throw new Error("PRIVILEGED_HANDLER_EXECUTED"); }; + turn.bridge = bridge; turn.posture = mode === "voice" ? "voice" : undefined; turn.noGrantSurface = mode === "no-grant"; + const decision = await decidePiToolPermission({ turn, allowedTools: new Set([name]), confirmPatterns: [] }, name, {}, "raft"); + expect(decision, `PI_GATE_REFUSAL_${mode}_${name}`).toMatchObject({ behavior: "deny" }); expect(cards).toBe(0); +}); diff --git a/packages/ui-backend-pi/tests/tools-permission.test.ts b/packages/ui-backend-pi/tests/tools-permission.test.ts index c3698ff750..7744bc11e3 100644 --- a/packages/ui-backend-pi/tests/tools-permission.test.ts +++ b/packages/ui-backend-pi/tests/tools-permission.test.ts @@ -100,6 +100,7 @@ describe("tool_call permission gate", () => { const turn = createTurnContext(); const mock = makeMockBridge({ decision: { behavior: "deny", message: "operator excluded base reads" } }); turn.bridge = mock.bridge; + turn.bridge = { ...mock.bridge, readBrainBase: async () => ({ content: "Odysseus returns.", expectedBaseHash: "a".repeat(64) }) }; turn.enforceAllowedTools = true; turn.noGrantSurface = noGrantSurface; const handler = gateHandler({ turn, allowedTools: new Set(["brain_read"]), confirmPatterns: CONFIRM }); diff --git a/scripts/check-citations.ts b/scripts/check-citations.ts index 0d7e7f2a2f..a5cffe2971 100644 --- a/scripts/check-citations.ts +++ b/scripts/check-citations.ts @@ -8,7 +8,7 @@ // wrong. The convention (`docs/decisions/README.md`) is to name the symbol and // let the range follow it: // -// (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:182-205`) +// (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`) // // The anchor is the code span immediately before the citation, joined to it by // a comma. Each cited range must START on a line containing the anchor, so an @@ -534,7 +534,7 @@ export const CITATION_EXCEPTIONS: Record = { "file-store.ts is now a shim; the cited read moved behind an injected env.storage()", "docs/decisions/design-kit/state-architecture.md|components/chat/renderers/index.ts:10": "the module-level registration latch this audit found was since removed", - "docs/decisions/design-kit/show-block-brief.md|packages/ui-backend-claude/src/ask-user-tool.ts:107": + "docs/decisions/design-kit/show-block-brief.md|packages/ui-backend-claude/src/ask-user-tool.ts:109": "records the call as it was before D44; it now passes alwaysLoad", "docs/decisions/claude-code-runtime.md|packages/ui-backend-claude/src/model-discovery.ts:86-104": "model discovery preferring the API key; #253 made it prefer the subscription token", From a866ad6a582fcb2df3457a577a95dc4c9d422edf Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:27:58 +0200 Subject: [PATCH 2/7] fix(backends): preserve deployment exclusions and reject unavailable applications --- packages/ui-backend-claude/src/sdk-options.ts | 4 ++-- .../ui-backend-claude/src/tool-availability.ts | 4 ++-- .../tests/fixtures/stable-roster-probe.ts | 14 +++++++++++++- .../tests/stable-roster-runtime.test.ts | 5 +++-- .../ui-backend-claude/tests/stable-roster.test.ts | 8 +++++--- packages/ui-backend-pi/src/worker-session.ts | 6 ++++-- .../tests/fixtures/stable-roster-probe.ts | 6 ++++-- .../tests/stable-roster-runtime.test.ts | 2 +- 8 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index 7a9e09ef26..672f19f788 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -223,8 +223,8 @@ function assembleClaudeSdkTurn( allowedTools: allowed, // The built-in AskUserQuestion picker needs a TTY; keep it disabled even // when no ask-user handler is present. - disallowedTools: ["AskUserQuestion", - "mcp__brain__brain_add", "mcp__brain__brain_update", "mcp__brain__brain_archive"], + disallowedTools: ["AskUserQuestion", ...((hosted || req.autonomous) ? + ["mcp__brain__brain_add", "mcp__brain__brain_update", "mcp__brain__brain_archive"] : [])], ...createPermissionWiring({ req: { ...req, workerSandbox: sandbox }, allowedTools: allowed, diff --git a/packages/ui-backend-claude/src/tool-availability.ts b/packages/ui-backend-claude/src/tool-availability.ts index 9f9afb64f9..5e27105fb0 100644 --- a/packages/ui-backend-claude/src/tool-availability.ts +++ b/packages/ui-backend-claude/src/tool-availability.ts @@ -17,8 +17,8 @@ export function toolAvailabilityRefusal(req: StartTurnRequest, toolName: string) if (req.autonomous) return undefined; const name = toolName.replace(/^mcp__brain-ui__/, ""); if (["Write", "Edit", "NotebookEdit"].includes(name) - && (req.posture === "voice" || req.workerSandbox?.profile === "strict")) - return "Native writes are unavailable on strict or voice turns."; + && (req.posture === "voice" || req.workerSandbox?.profile !== "governed")) + return "Native writes require a governed typed turn."; if ((visual.has(name) || interactive.has(name)) && (req.noGrantSurface || req.posture === "voice")) return `Tool "${name}" requires an interactive typed grant surface.`; const handler = handlers[name]; diff --git a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts index c3d6bf1856..72d47842eb 100644 --- a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts +++ b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts @@ -27,6 +27,17 @@ const inputs: Record> = { Edit: { file_path: join(brain, "notes/raft.md"), old_string: "Odysseus", new_string: "MUTATION" }, NotebookEdit: { notebook_path: join(brain, "notes/loom.ipynb"), cell_id: "loom", new_source: "MUTATION", edit_mode: "replace" }, }; +const missingInputs: typeof inputs = { + ask_user: { questions: [{ question: "Which route?", header: "Route", multiSelect: false, options: [{ label: "Raft", description: "Repair raft" }, { label: "Ship", description: "Wait for ship" }] }] }, + get_current_location: {}, query_activity: { scope: "running" }, + brain_read_base: { path: "notes/raft.md" }, + brain_add: { content: "Odysseus plans the voyage." }, + brain_update: { path: "notes/raft.md", expectedBaseHash: null, append_content: "Repair mast" }, + brain_archive: { path: "notes/raft.md", expectedBaseHash: null }, + write_file: { path: "notes/raft.md", expectedBaseHash: null, content: "MUTATION" }, + edit_file: { path: "notes/raft.md", expectedBaseHash: null, old_string: "Odysseus", new_string: "MUTATION" }, + apply_staged_changes: { files: [{ path: "notes/raft.md", expectedBaseHash: null, content: "MUTATION" }] }, +}; const captures: { mode: string; tools: unknown; results: unknown[]; denials: string[]; calls: string[]; cards: number }[] = []; let active: typeof captures[number], pending = true, pendingRead = true; const names = Object.keys(inputs); @@ -39,8 +50,9 @@ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) if (!pending) return response([]); pending = false; const selected = active.mode === "typed" ? ["ask_user_list"] : active.mode === "strict" ? names.filter(n => !n.startsWith("mcp") && /^[A-Z]/.test(n)) : active.mode === "voice" ? names : names.filter(n => !/^[A-Z]/.test(n)); + if (active.mode === "missing") selected.push(...Object.keys(missingInputs)); return response(selected.map((name, index) => ({ type: "tool_use", id: `toolu_raft_${captures.length}_${index}`, - name: /^[A-Z]/.test(name) ? name : `mcp__brain-ui__${name}`, input: inputs[name] }))); + name: /^[A-Z]/.test(name) ? name : `mcp__brain-ui__${name}`, input: inputs[name] ?? missingInputs[name] }))); } }); function response(calls: unknown[]) { const content: any[] = calls.length ? calls : [{ type: "text", text: "Odysseus checks the mast." }]; diff --git a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts index 0d6e21e0ea..109a86cacf 100644 --- a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts +++ b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts @@ -15,6 +15,7 @@ test("installed Claude keeps request tools fixed and refuses every unavailable t const { captures, raft, loom } = JSON.parse(readFileSync(join(scratch, "requests.json"), "utf8")); const bridgeNames = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"].map(name => `mcp__brain-ui__${name}`); const natives = ["Write", "Edit", "NotebookEdit"]; + const missing = ["ask_user", "get_current_location", "query_activity", "brain_read_base", "brain_add", "brain_update", "brain_archive", "write_file", "edit_file", "apply_staged_changes"].map(name => `mcp__brain-ui__${name}`); expect(captures).toHaveLength(7); expect(new Set(captures.slice(0, 3).map((c: any) => c.sessionId)).size).toBe(1); expect(new Set(captures.slice(3).map((c: any) => c.sessionId)).size).toBe(1); @@ -23,10 +24,10 @@ test("installed Claude keeps request tools fixed and refuses every unavailable t expect(JSON.stringify(capture.tools), `BYTE_IDENTICAL_PROVIDER_TOOLS_${capture.mode}`).toBe(JSON.stringify(captures[0].tools)); expect(capture.cards, `NO_PERMISSION_CARDS_${capture.mode}`).toBe(0); if (capture.mode === "typed") { expect(capture.calls).toEqual(["askUserList"]); expect(capture.results[0].is_error).not.toBe(true); continue; } - const expected = capture.mode === "strict" ? natives : capture.mode === "voice" ? [...bridgeNames, ...natives] : bridgeNames; + const expected = capture.mode === "strict" ? natives : capture.mode === "voice" ? [...bridgeNames, ...natives] : capture.mode === "missing" ? [...bridgeNames, ...missing] : bridgeNames; for (const name of expected) expect(capture.denials, `RUNTIME_HOOK_REFUSAL_${capture.mode}_${name}: ${JSON.stringify(capture.results)}`).toContain(name); expect(capture.calls, `NO_PRIVILEGED_HANDLER_${capture.mode}`).toEqual([]); - expect(capture.results.length).toBe(capture.mode === "strict" ? 3 : capture.mode === "voice" ? 11 : 8); + expect(capture.results.length).toBe(capture.mode === "strict" ? 3 : capture.mode === "voice" ? 11 : capture.mode === "missing" ? 18 : 8); expect(capture.results.every((r: any) => r.is_error === true), `MODEL_READABLE_REFUSALS_${capture.mode}`).toBe(true); } expect(raft).toBe("Odysseus repairs the raft.\n"); expect(loom).not.toContain("MUTATION"); diff --git a/packages/ui-backend-claude/tests/stable-roster.test.ts b/packages/ui-backend-claude/tests/stable-roster.test.ts index cdb93e8318..9823d8078e 100644 --- a/packages/ui-backend-claude/tests/stable-roster.test.ts +++ b/packages/ui-backend-claude/tests/stable-roster.test.ts @@ -30,9 +30,11 @@ test("Claude complete tool schemas and native exclusions stay identical voice-fi await (turn.options.mcpServers!["brain-ui"] as any).instance.connect(st); await client.connect(ct); const tools = (await client.listTools()).tools; + for (const legacy of ["brain_add", "brain_update", "brain_archive"]) + expect(turn.options.disallowedTools?.includes(`mcp__brain__${legacy}`), `LEGACY_DEPLOYMENT_MEMBERSHIP_${mode}_${legacy}`).toBe(mode !== "missing"); for (const name of [...refused, "brain_read_base", "brain_add", "show_block", "turn_temp"]) expect(tools.some(tool => tool.name === name), `FULL_ROSTER_${mode}_${name}`).toBe(true); - rosters.push(JSON.stringify(tools)); exclusions.push(JSON.stringify(turn.options.disallowedTools)); + rosters.push(JSON.stringify(tools)); exclusions.push(JSON.stringify(turn.options.disallowedTools?.filter(name => ["Write", "Edit", "NotebookEdit"].includes(name)))); } finally { await client.close(); await turn.finishWorker(); } } for (const roster of rosters.slice(1)) expect(roster, "BYTE_IDENTICAL_TOOL_SCHEMAS").toBe(rosters[0]); @@ -54,9 +56,9 @@ for (const mode of ["voice", "no-grant", "missing"] as const) for (const name of expect(await wiring.canUseTool!(`mcp__brain-ui__${name}`, {}, { toolUseID: "loom" } as never)).toMatchObject({ behavior: "deny" }); expect(cards).toBe(0); }); -for (const mode of ["voice", "strict"] as const) for (const name of ["Write", "Edit", "NotebookEdit"]) test(`Claude ${mode} denies registered native ${name}`, async () => { +for (const mode of ["voice", "strict", "unknown"] as const) for (const name of ["Write", "Edit", "NotebookEdit"]) test(`Claude ${mode} denies registered native ${name}`, async () => { const req = { signal: new AbortController().signal, bridge: { requestPermission: async () => { throw new Error("CARD_OPENED"); } }, - workerSandbox: { profile: mode === "strict" ? "strict" : "governed" }, ...(mode === "voice" ? { posture: "voice" } : {}) }; + workerSandbox: { profile: mode === "unknown" ? undefined : mode === "strict" ? "strict" : "governed" }, ...(mode === "voice" ? { posture: "voice" } : {}) }; const wiring = createPermissionWiring({ req: req as never, allowedTools: [name], brainPath: "/tmp/odysseus", confirmPatterns: [], turnLock: { releaseForTool() {} } as never, childEnv: {}, log() {} }); const hook = wiring.hooks!.PreToolUse!.find(group => !group.matcher)!.hooks[0]!; diff --git a/packages/ui-backend-pi/src/worker-session.ts b/packages/ui-backend-pi/src/worker-session.ts index 61f3d93473..cda4e5a7ab 100644 --- a/packages/ui-backend-pi/src/worker-session.ts +++ b/packages/ui-backend-pi/src/worker-session.ts @@ -362,8 +362,10 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi if (isCompletedAutonomousToolCall(turnContext.autonomous, msg.toolName, msg.input)) { return { behavior: "deny", message: "This call already completed before yielding; inspect its retained result instead of replaying it." }; } - if (bridge.applyBrain && Object.values(BRAIN_APPLICATION_TOOLS).includes(msg.toolName as any) - && !(workerSandbox?.profile === "governed" && ["write_file", "edit_file"].includes(msg.toolName))) { + const application = Object.values(BRAIN_APPLICATION_TOOLS).includes(msg.toolName as any) + && !(workerSandbox?.profile === "governed" && ["write_file", "edit_file"].includes(msg.toolName)); + if (application && !bridge.applyBrain) return { behavior: "deny", message: "No server application route is available in this turn." }; + if (application && bridge.applyBrain) { // Exact application authorization belongs to the server route. return { behavior: "allow" }; } diff --git a/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts b/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts index 2201048e1c..8a0f72cd93 100644 --- a/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts +++ b/packages/ui-backend-pi/tests/fixtures/stable-roster-probe.ts @@ -17,13 +17,15 @@ const inputs: Record = { resolve_action: { sessionId: "voyage", itemId: "raft", optionId: "keep", expectedVersion: 1 }, complete_share: { receiptIds: ["raft"], files: [] }, }; +const missingInputs = { brain_archive: { path: "notes/raft.md", expectedBaseHash: null }, brain_add: { content: "Odysseus plans the voyage." }, brain_update: { path: "notes/raft.md", expectedBaseHash: null, append_content: "Repair mast" }, apply_staged_changes: { files: [{ path: "notes/raft.md", expectedBaseHash: null, content: "MUTATION" }] } }; const captures: any[] = []; let active: any, next = 0; const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { if (new URL(request.url).pathname !== "/v1/responses") throw new Error("Unexpected endpoint"); const body = await request.json() as any; active.tools ??= body.tools; - const name = active.mode === "typed" ? (next === 0 ? "ask_user_list" : undefined) : Object.keys(inputs)[next]; + const selected = active.mode === "missing" ? { ...inputs, ...missingInputs } : inputs; + const name = active.mode === "typed" ? (next === 0 ? "ask_user_list" : undefined) : Object.keys(selected)[next]; next++; - const id = `raft_${next}`, answer: any = name ? { type: "function_call", id: `fc_${id}`, call_id: `call_${id}`, name, arguments: JSON.stringify(inputs[name]) } + const id = `raft_${next}`, answer: any = name ? { type: "function_call", id: `fc_${id}`, call_id: `call_${id}`, name, arguments: JSON.stringify(selected[name]) } : { type: "message", role: "assistant", content: [{ type: "output_text", text: "Odysseus checks the mast.", annotations: [] }], status: "completed", id }; const response = { id: `resp_${id}`, object: "response", status: "completed", model: "gpt-4o-mini", output: [answer], usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } }; const events = [{ type: "response.created", response: { ...response, status: "in_progress", output: [] } }, diff --git a/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts b/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts index 0db7ab023e..d9731db3b6 100644 --- a/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts +++ b/packages/ui-backend-pi/tests/stable-roster-runtime.test.ts @@ -17,7 +17,7 @@ test("Pi native workers retain full schemas across no-grant, typed and missing-h expect(capture.cards).toBe(0); const results = capture.frames.filter((frame: any) => frame.type === "tool_result"); if (capture.mode === "typed") { expect(capture.calls).toEqual(["askUserList"]); expect(results[0].isError).not.toBe(true); continue; } - expect(capture.calls).toEqual([]); expect(results.length).toBe(names.length); + expect(capture.calls).toEqual([]); expect(results.length).toBe(names.length + (capture.mode === "missing" ? 4 : 0)); for (const result of results) expect(result.isError, `PI_MODEL_READABLE_REFUSAL_${capture.mode}_${result.toolUseId}`).toBe(true); } expect(voiceRefused).toBe(true); expect(unchanged).toBe(true); From 1e1c99f9e363c98c6c835dce67b7b9eb5ee56baa Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:41:55 +0200 Subject: [PATCH 3/7] CONTRACT: declare stable hosted application ownership per deployment --- .changeset/stable-interactive-tool-rosters.md | 2 ++ api-report/ui-sdk.txt | 1 + docs/decisions/async-collaboration.md | 2 +- docs/decisions/live-conversation.md | 2 +- docs/decisions/session-principals.md | 2 +- docs/decisions/voice-permission.md | 2 +- docs/integration-contract/mcp.md | 6 +++++- docs/integration-contract/package-api.md | 13 +++++++++++++ .../live-conversation-investigation.md | 4 ++-- docs/plans/async-collaboration-requirements.md | 4 ++-- docs/plans/async-collaboration.md | 6 +++--- docs/plans/navigation-recovery.md | 2 +- packages/ui-backend-claude/src/sdk-options.ts | 2 +- .../tests/fixtures/legacy-mcp-server.ts | 9 +++++++++ .../tests/fixtures/stable-roster-probe.ts | 17 ++++++++++------- .../tests/stable-roster-runtime.test.ts | 12 ++++++++++-- .../tests/stable-roster.test.ts | 7 ++++--- packages/ui-sdk/src/server/backend.ts | 6 ++++++ packages/ui-server/src/ws/run-session.ts | 1 + .../tests/brain-application-adapters.test.ts | 2 +- .../tests/ws-client-environment.test.ts | 11 ++++++++++- 21 files changed, 85 insertions(+), 28 deletions(-) create mode 100644 packages/ui-backend-claude/tests/fixtures/legacy-mcp-server.ts diff --git a/.changeset/stable-interactive-tool-rosters.md b/.changeset/stable-interactive-tool-rosters.md index d0a1766052..a15336eda0 100644 --- a/.changeset/stable-interactive-tool-rosters.md +++ b/.changeset/stable-interactive-tool-rosters.md @@ -1,6 +1,8 @@ --- "@schlessera/brain-backend-claude": minor "@schlessera/brain-backend-pi": minor +"@schlessera/brain-ui-sdk": minor +"@schlessera/brain-ui-server": minor --- Keep interactive tool schemas stable across posture, strict fallback and temporary host capability changes. Refuse unavailable calls before privileged handlers or approval cards, preserving voice and sandbox authority. diff --git a/api-report/ui-sdk.txt b/api-report/ui-sdk.txt index 3ef3ac7931..b2cde460b2 100644 --- a/api-report/ui-sdk.txt +++ b/api-report/ui-sdk.txt @@ -9159,6 +9159,7 @@ signatures (every public export and the types it is made of) turnBudgetMs?: number; maxBudgetUsd?: number; bridge: BackendBridge; + applicationAuthority?: "server"; client?: ClientEnvironment; enforceAllowedTools?: boolean; noGrantSurface?: boolean; diff --git a/docs/decisions/async-collaboration.md b/docs/decisions/async-collaboration.md index ee9ee3a3ab..5e14f1d385 100644 --- a/docs/decisions/async-collaboration.md +++ b/docs/decisions/async-collaboration.md @@ -71,7 +71,7 @@ requires one enforcement mechanism with different named memberships for voice and unattended work. Keep that requirement when implementing availability control. Existing mandatory backend conformance supports safe rejection of an unsupported restricted request; silently ignoring it is forbidden. The current -optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:529`) and (`noGrantSurface?: boolean`, `packages/ui-sdk/src/server/backend.ts:553`). These are permission primitives, +optional inputs are (`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:535`) and (`noGrantSurface?: boolean`, `packages/ui-sdk/src/server/backend.ts:559`). These are permission primitives, not a claim of filesystem or network containment. The ordinary Claude assembly loads project settings and appends bridge tools diff --git a/docs/decisions/live-conversation.md b/docs/decisions/live-conversation.md index c80c196311..d78462c925 100644 --- a/docs/decisions/live-conversation.md +++ b/docs/decisions/live-conversation.md @@ -30,7 +30,7 @@ tool admission and execution, permissions, cancellation, terminal state and resynchronization. Engine task requests are advisory until mapped to committed user input. No native search, code-execution or alternate permission loop is enabled. Both adapters can delegate through the existing `AgentBackend` -(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:634-664`); +(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:640-670`); the host serializes work in a session and uses capability-checked follow-ups. The [voice-permission decision](voice-permission.md) binds this interface. diff --git a/docs/decisions/session-principals.md b/docs/decisions/session-principals.md index 5809a889e8..0dc85b40b4 100644 --- a/docs/decisions/session-principals.md +++ b/docs/decisions/session-principals.md @@ -102,7 +102,7 @@ Both reviewers verified every row below against the source. | WS upgrade | `ws/connection.ts:262-264` | `createWsUpgrade` ignores the request context | | WS admission | `ws/connection.ts:84` | `clients.add(ws)` — the socket's identity is unknown | | Turn record | `ws/run-session.ts:134-142` | recorder built from `{turnId, sessionId, billing}`; `RunningTurn` (`ws/turns.ts:55-77`) has no actor | -| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:895` | queued entries re-mint `turnId` in the same slot and may come from another socket | +| Follow-up queue | `QueuedFollowUp`, `ws/turns.ts:61-103`; `slot.queue.push(entry)`, `run-session.ts:896` | queued entries re-mint `turnId` in the same slot and may come from another socket | | Password login | `acquirePasswordVerification(key)`, `auth.ts:740` | argon2id verify, failure counting, in-flight reservation, then `issueSessionCookie` | | Passkey login | `issueLoginSession`, `packages/ui-server/src/middleware/passkeys.ts:442` | assertion verified, then `issueSessionCookie`; `row.id` is in scope | | Passkey **registration** | `INSERT INTO passkey_credentials`, `passkeys.ts:540` | inserts a credential; calls **neither** helper | diff --git a/docs/decisions/voice-permission.md b/docs/decisions/voice-permission.md index 4ba13a3207..de4bde26dc 100644 --- a/docs/decisions/voice-permission.md +++ b/docs/decisions/voice-permission.md @@ -590,7 +590,7 @@ matters because a reader cannot otherwise tell a live hazard from a fixed one. **Closed, by #141 (`42a4d86`), which closed #124.** A turn now declares `StartTurnRequest.enforceAllowedTools` -(`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:529`) +(`enforceAllowedTools?: boolean`, `packages/ui-sdk/src/server/backend.ts:535`) — the declaration this record asked for, by that name — and under it: - The input-rewrite hooks no longer grant. `createAgentHook` and `createRtkHook` diff --git a/docs/integration-contract/mcp.md b/docs/integration-contract/mcp.md index 6735247e6a..7b7b2f85f2 100644 --- a/docs/integration-contract/mcp.md +++ b/docs/integration-contract/mcp.md @@ -222,7 +222,11 @@ remembered allows. Governed typed calls retain their stale-read and write-lock checks. Pi still rejects voice dispatch before a provider request: the stable roster does not add a voice posture. Restricted autonomous rosters remain exact server authority and are outside this interactive stability rule. Existing tool -names, inputs and result shapes are unchanged. +names, inputs and result shapes are unchanged. Hosted legacy project-MCP +shadowing uses the session-stable trusted +`StartTurnRequest.applicationAuthority: "server"` declaration even when its +route handler is absent; direct unhosted consumers retain their legacy tools. +See [the package API](package-api.md#stable-hosted-application-roster-2048). The chat-UI backends register an in-process MCP server under the `brain-ui` key; its tool names are equally stable. `ask_user`, `ask_user_form` (additive in 0.40.0), `ask_user_rank` (additive in 0.40.0), `ask_user_list` (additive diff --git a/docs/integration-contract/package-api.md b/docs/integration-contract/package-api.md index 2c7064f575..ebc130b7bd 100644 --- a/docs/integration-contract/package-api.md +++ b/docs/integration-contract/package-api.md @@ -3196,3 +3196,16 @@ explanation. `showActions={false}` and closed exchanges still omit actions. The client preserves selected predefined answers beside custom text in a multi-select and the existing grouped-question submission path. No wire, CLI/MCP or answer-delivery contract changes. + +### Stable hosted application roster (#2048) + +The additive, trusted `StartTurnRequest.applicationAuthority?: "server"` field +identifies a deployment whose authoritative mutations use the server route. +The UI server supplies it on every interactive turn, independent of posture, +strict fallback or temporary bridge handler availability. It is session-stable +host configuration, never client/model input or a grant. Claude uses it to keep +legacy project MCP writers shadowed even when `applyBrain` is temporarily absent; +its fixed in-process application tools then refuse unavailable routes at call +time. Direct unhosted consumers omitting it retain their legacy project MCP +roster. Existing custom bridges without the field retain the prior `applyBrain` +presence fallback. Autonomous authority/rosters are unchanged. diff --git a/docs/investigations/live-conversation-investigation.md b/docs/investigations/live-conversation-investigation.md index 6df9bd47d3..9d2186cd75 100644 --- a/docs/investigations/live-conversation-investigation.md +++ b/docs/investigations/live-conversation-investigation.md @@ -230,7 +230,7 @@ requestId)`; actual tool requests additionally use their existing `toolUseId`. Only committed original input supplies `StartTurnRequest.prompt`. The host selects the pinned existing backend/profile, cancellation signal and `BackendBridge`, and enforces the voice posture on new voice turns -(`export interface StartTurnRequest`, `packages/ui-sdk/src/server/backend.ts:470-565`). +(`export interface StartTurnRequest`, `packages/ui-sdk/src/server/backend.ts:470-571`). Do not feed approximate fragments or native task descriptions directly into execution. Keep raw outputs/private memory in the host and return only reviewed bounded facts needed by the voice service. @@ -239,7 +239,7 @@ Backend reuse is structurally feasible for **both** adapters. `startTurn` is already asynchronous and accepts a host AbortSignal. A busy same-session turn is queued; new committed details use `followUp` only when the backend advertises it, otherwise remain queued/reviewable -(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:634-664`). +(`export interface AgentBackend`, `packages/ui-sdk/src/server/backend.ts:640-670`). Continued voice interaction never requires unsafe concurrent `startTurn`s. This is source evidence and design mapping, not a real provider/backend trial. diff --git a/docs/plans/async-collaboration-requirements.md b/docs/plans/async-collaboration-requirements.md index e60219af08..4aa6eea8a1 100644 --- a/docs/plans/async-collaboration-requirements.md +++ b/docs/plans/async-collaboration-requirements.md @@ -248,7 +248,7 @@ Stated before the requirements because five of them derive from it. - R24. **Headless execution needs a new request shape.** `StartTurnRequest` is additive: `StartTurnRequest.autonomous` carries explicit persistence, origin, tool policy and prompt configuration alongside the required permission postures - (`export interface StartTurnRequest {`, `packages/ui-sdk/src/server/backend.ts:470-558`); ordinary Claude turns create an SDK session, emit + (`export interface StartTurnRequest {`, `packages/ui-sdk/src/server/backend.ts:470-564`); ordinary Claude turns create an SDK session, emit `session_info`, and persists history by default. The installed SDK supports `persistSession: false`; pi provides `SessionManager.inMemory`. Drive this mode with a synthetic checkpoint bridge and server-selected authority; the @@ -256,7 +256,7 @@ Stated before the requirements because five of them derive from it. (`export function createTurnRecorder(`, `packages/ui-server/src/activity/recorder.ts:100-171`). - R25. **Autonomous work gets its own pool** (`MAX_AUTONOMOUS_RUNS`, default 2) — but a second counter alone does not deliver "interactive always wins". The host cap applies only when - starting WS sessions (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1105-1117`), and an autonomous + starting WS sessions (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1106-1118`), and an autonomous turn can hold a path write lock while an interactive turn waits or is denied at 30 seconds (`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`). Required: an admission controller with reserved interactive capacity and hybrid yield at an explicit denial-risk diff --git a/docs/plans/async-collaboration.md b/docs/plans/async-collaboration.md index 9c4e2b5612..081fa0c8a0 100644 --- a/docs/plans/async-collaboration.md +++ b/docs/plans/async-collaboration.md @@ -156,7 +156,7 @@ system gate. It is not a prerequisite to rebuild or a new independent package. - **Share provenance/limits:** (`const result = await stageShareAt(`, `packages/ui-server/src/inbox/intake.ts:95`) assigns the source in server code. (`export const SHARE_MAX_TEXT_BYTES =`, `packages/ui-sdk/src/protocol.ts:2325`) bounds text, not binary uploads; extracted T1 context needs its own byte/token bound. -- **Interactive locks:** (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1105-1117`) gates WS starts; +- **Interactive locks:** (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1106-1118`) gates WS starts; (`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`) owns tool locks. Shared-target priority and cooperative yield belong to the keyed lock and backend lifecycle. @@ -1189,7 +1189,7 @@ and a server-selected tool policy. **Files:** - Modify: `packages/ui-sdk/src/server/backend.ts` (the additive `StartTurnRequest.autonomous` mode carries persistence, origin, tool policy and prompt configuration — - (`export interface StartTurnRequest {`, `packages/ui-sdk/src/server/backend.ts:470-558`)) + (`export interface StartTurnRequest {`, `packages/ui-sdk/src/server/backend.ts:470-564`)) - Modify: `packages/ui-backend-claude/src/backend.ts` (`persistSession: false`, synthetic bridge) - Modify: `packages/ui-server/src/activity/recorder.ts` (server-selected @@ -1274,7 +1274,7 @@ full-v1 enablement; predicates and schema tests cannot substitute for it. **Approach:** **hybrid — reserve capacity normally, yield only at denial risk.** `MAX_AUTONOMOUS_RUNS` (default 2) is necessary but not sufficient: the host cap applies only at -WS session start (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1105-1117`) and an autonomous turn can hold a path write lock +WS session start (`const cap = host.maxConcurrentSessions();`, `packages/ui-server/src/ws/run-session.ts:1106-1118`) and an autonomous turn can hold a path write lock while an interactive turn waits or is denied at 30s (`export function createTurnLockBinding(`, `packages/ui-backend-claude/src/turn-lock.ts:27-119`). diff --git a/docs/plans/navigation-recovery.md b/docs/plans/navigation-recovery.md index dd3eaec7f2..3a2f993abe 100644 --- a/docs/plans/navigation-recovery.md +++ b/docs/plans/navigation-recovery.md @@ -399,7 +399,7 @@ implementation. Any PR moving these lines owns their citation repairs. - API authorization: (`app.use("/api/*", authGuard(authMode, auth, db));`, `packages/ui-server/src/app.ts:746`). -- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:878-922`). +- Queued acceptance: (`// Queue it as the session's next turn; report queued immediately.`, `packages/ui-server/src/ws/run-session.ts:879-923`). - Approval target: (`function mutateLastAssistant(`, `packages/ui-react/src/stores/chat-state.ts:1217-1247`). diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index 672f19f788..d572430545 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -119,7 +119,7 @@ function assembleClaudeSdkTurn( // it by the server's held descriptor, so it needs no approval. Not in the // restricted envelope, whose roster is exact server authority. const turnTempTool = !req.autonomous; - const hosted = Boolean(req.bridge.applyBrain); + const hosted = (!req.autonomous && req.applicationAuthority === "server") || Boolean(req.bridge.applyBrain); const migrations: Record = { mcp__brain__brain_add: "mcp__brain-ui__brain_add", mcp__brain__brain_update: "mcp__brain-ui__brain_update", mcp__brain__brain_archive: "mcp__brain-ui__brain_archive", Write: "mcp__brain-ui__write_file", Edit: "mcp__brain-ui__edit_file", diff --git a/packages/ui-backend-claude/tests/fixtures/legacy-mcp-server.ts b/packages/ui-backend-claude/tests/fixtures/legacy-mcp-server.ts new file mode 100644 index 0000000000..0dbdde6ff0 --- /dev/null +++ b/packages/ui-backend-claude/tests/fixtures/legacy-mcp-server.ts @@ -0,0 +1,9 @@ +/** Offline project MCP fixture: real unhosted legacy tool membership. */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +const server = new McpServer({ name: "odysseus-legacy", version: "1" }); +for (const name of ["brain_add", "brain_update", "brain_archive"]) server.registerTool(name, { + description: `Odysseus legacy ${name}`, inputSchema: z.object({}), _meta: { "anthropic/alwaysLoad": true }, +}, async () => ({ content: [{ type: "text", text: "LEGACY_HANDLER_EXECUTED" }] })); +await server.connect(new StdioServerTransport()); diff --git a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts index 72d47842eb..1d55680949 100644 --- a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts +++ b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts @@ -12,7 +12,8 @@ mkdirSync(join(brain, "notes"), { recursive: true }); mkdirSync(join(brain, ".cl writeFileSync(join(brain, "notes/raft.md"), "Odysseus repairs the raft.\n"); writeFileSync(join(brain, "notes/loom.ipynb"), JSON.stringify({ cells: [{ cell_type: "code", id: "loom", source: ["Penelope weaves."], outputs: [], metadata: {}, execution_count: null }], metadata: {}, nbformat: 4, nbformat_minor: 5 })); // A remembered/settings allow and a project hook cannot overrule a host deny. -writeFileSync(join(brain, ".claude/settings.json"), JSON.stringify({ permissions: { allow: ["Write", "Edit", "NotebookEdit", "mcp__brain-ui__*"] }, +writeFileSync(join(brain, ".mcp.json"), JSON.stringify({ mcpServers: { brain: { command: process.execPath, args: [join(import.meta.dir, "legacy-mcp-server.ts")] } } })); +writeFileSync(join(brain, ".claude/settings.json"), JSON.stringify({ enableAllProjectMcpServers: true, permissions: { allow: ["Write", "Edit", "NotebookEdit", "mcp__brain-ui__*"] }, hooks: { PreToolUse: [{ matcher: ".*", hooks: [{ type: "command", command: `printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'` }] }] } })); const inputs: Record> = { request_image_mask: { imagePath: "assets/raft.png" }, @@ -49,10 +50,10 @@ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) if (pendingRead) { pendingRead = false; return response([{ type: "tool_use", id: "toolu_loom_read", name: "Read", input: { file_path: join(brain, "notes/loom.ipynb") } }]); } if (!pending) return response([]); pending = false; - const selected = active.mode === "typed" ? ["ask_user_list"] : active.mode === "strict" ? names.filter(n => !n.startsWith("mcp") && /^[A-Z]/.test(n)) : active.mode === "voice" ? names : names.filter(n => !/^[A-Z]/.test(n)); + const selected = active.mode === "unhosted" ? ["mcp__brain__brain_add"] : active.mode === "typed" ? ["ask_user_list"] : active.mode === "strict" ? names.filter(n => !n.startsWith("mcp") && /^[A-Z]/.test(n)) : active.mode === "voice" ? names : names.filter(n => !/^[A-Z]/.test(n)); if (active.mode === "missing") selected.push(...Object.keys(missingInputs)); return response(selected.map((name, index) => ({ type: "tool_use", id: `toolu_raft_${captures.length}_${index}`, - name: /^[A-Z]/.test(name) ? name : `mcp__brain-ui__${name}`, input: inputs[name] ?? missingInputs[name] }))); + name: /^[A-Z]/.test(name) || name.startsWith("mcp__") ? name : `mcp__brain-ui__${name}`, input: inputs[name] ?? missingInputs[name] ?? {} }))); } }); function response(calls: unknown[]) { const content: any[] = calls.length ? calls : [{ type: "text", text: "Odysseus checks the mast." }]; @@ -64,24 +65,25 @@ function response(calls: unknown[]) { } let sessionId: string | undefined; try { - for (const mode of ["typed", "voice", "strict", "voice", "typed", "missing", "no-grant"]) { - if (captures.length === 3) sessionId = undefined; + for (const mode of ["typed", "voice", "strict", "voice", "typed", "missing", "no-grant", "unhosted", "unhosted"]) { + if ([3, 7].includes(captures.length)) sessionId = undefined; active = { mode, tools: undefined, results: [], denials: [], calls: [], cards: 0 }; captures.push(active); pending = true; pendingRead = true; const controller = new AbortController(); const sandbox = prepareFixtureWorkerSandbox(brain, { home, profile: mode === "strict" ? "strict" : "governed", areas: [] }); const view = createFixtureWorkerStartupView(brain); const unavailable = (name: string) => async () => { active.calls.push(name); throw new Error("PRIVILEGED_HANDLER_EXECUTED"); }; const bridge: any = { emit() {}, checkpointPermission() {}, async requestPermission() { active.cards++; return { behavior: "allow" }; } }; - if (mode !== "missing") for (const name of ["askUserList", "askUserRank", "askUserForm", "requestMask", "proposePolicyChange", "inboxList", "inboxGet", "inboxAdd", "resolveAction", "completeShare", "applyBrain", "readBrainBase"]) bridge[name] = unavailable(name); + if (!["missing", "unhosted"].includes(mode)) for (const name of ["askUserList", "askUserRank", "askUserForm", "requestMask", "proposePolicyChange", "inboxList", "inboxGet", "inboxAdd", "resolveAction", "completeShare", "applyBrain", "readBrainBase"]) bridge[name] = unavailable(name); if (mode === "typed") bridge.askUserList = async () => { active.calls.push("askUserList"); return { answers: { raft: "Keep" }, skipped: [] }; }; const lock = createTurnLockBinding({ turn: { ended: false, pendingReleases: new Map() }, writeLock: createKeyedLock(), lockWaitMs: 1000, log() {} }); const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { prompt: "Check the voyage", sessionId, turnId: `voyage-${mode}`, signal: controller.signal, workerSandbox: sandbox, workerStartupView: view, + ...(mode !== "unhosted" ? { applicationAuthority: "server" as const } : {}), bridge, noGrantSurface: mode === "voice" || mode === "no-grant", ...(mode === "voice" ? { posture: "voice", enforceAllowedTools: true } : {}), }, profile: { id: "fixture", label: "Fixture", model: "claude-sonnet-4-6", billing: "api", requiredEnvKeys: [], buildEnv: () => ({ HOME: home, CLAUDE_CONFIG_DIR: join(home, ".claude"), ANTHROPIC_BASE_URL: server.url.origin, ANTHROPIC_API_KEY: "offline-odysseus", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1", - }) }, abortController: controller, allowedTools: ["Read", "Write", "Edit", "NotebookEdit", ...BRIDGE_TOOL_POSTURE.allowedTools("claude")], confirmPatterns: [], turnLock: lock, log() {} }); + }) }, abortController: controller, allowedTools: ["Read", "Write", "Edit", "NotebookEdit", ...BRIDGE_TOOL_POSTURE.allowedTools("claude"), "mcp__brain__brain_add"], confirmPatterns: [], turnLock: lock, log() {} }); turn.options.hooks!.PreToolUse = turn.options.hooks!.PreToolUse!.map(group => ({ ...group, hooks: group.hooks.map(hook => async (...args) => { const result = await hook(...args); if ("hookSpecificOutput" in result && result.hookSpecificOutput?.hookEventName === "PreToolUse" && result.hookSpecificOutput.permissionDecision === "deny" && args[0].hook_event_name === "PreToolUse") active.denials.push(args[0].tool_name); @@ -90,6 +92,7 @@ try { const timer = setTimeout(() => controller.abort(), 30_000); try { for await (const message of query({ prompt: turn.prompt, options: turn.options })) { + if (message.type === "system" && message.subtype === "init") Object.assign(active, { mcp: message.mcp_servers }); if (message.type === "user" && Array.isArray(message.message.content)) for (const block of message.message.content) if (block.type === "tool_result") { if (block.tool_use_id !== "toolu_loom_read") active.results.push(block); lock.releaseForTool(block.tool_use_id); } if (message.type === "result") { if (message.subtype !== "success") throw new Error(JSON.stringify(message)); sessionId = message.session_id; } } diff --git a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts index 109a86cacf..b2851d2aa9 100644 --- a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts +++ b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts @@ -16,11 +16,19 @@ test("installed Claude keeps request tools fixed and refuses every unavailable t const bridgeNames = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"].map(name => `mcp__brain-ui__${name}`); const natives = ["Write", "Edit", "NotebookEdit"]; const missing = ["ask_user", "get_current_location", "query_activity", "brain_read_base", "brain_add", "brain_update", "brain_archive", "write_file", "edit_file", "apply_staged_changes"].map(name => `mcp__brain-ui__${name}`); - expect(captures).toHaveLength(7); + expect(captures).toHaveLength(9); expect(new Set(captures.slice(0, 3).map((c: any) => c.sessionId)).size).toBe(1); - expect(new Set(captures.slice(3).map((c: any) => c.sessionId)).size).toBe(1); + expect(new Set(captures.slice(3, 7).map((c: any) => c.sessionId)).size).toBe(1); expect(captures[0].sessionId).not.toBe(captures[3].sessionId); for (const capture of captures) { + if (capture.mode === "unhosted") { + expect(JSON.stringify(capture.tools), "BYTE_IDENTICAL_UNHOSTED_TOOLS").toBe(JSON.stringify(captures[7].tools)); + for (const name of ["brain_add", "brain_update", "brain_archive"]) + expect(capture.tools.some((tool: any) => tool.name === `mcp__brain__${name}`), `UNHOSTED_LEGACY_ROSTER_${name}`).toBe(true); + expect(capture.cards).toBe(0); expect(capture.results[0].is_error).not.toBe(true); + expect(JSON.stringify(capture.results[0]), "LEGACY_UNHOSTED_AUTHORITY_RETAINED").toContain("LEGACY_HANDLER_EXECUTED"); + continue; + } expect(JSON.stringify(capture.tools), `BYTE_IDENTICAL_PROVIDER_TOOLS_${capture.mode}`).toBe(JSON.stringify(captures[0].tools)); expect(capture.cards, `NO_PERMISSION_CARDS_${capture.mode}`).toBe(0); if (capture.mode === "typed") { expect(capture.calls).toEqual(["askUserList"]); expect(capture.results[0].is_error).not.toBe(true); continue; } diff --git a/packages/ui-backend-claude/tests/stable-roster.test.ts b/packages/ui-backend-claude/tests/stable-roster.test.ts index 9823d8078e..0d0fcac649 100644 --- a/packages/ui-backend-claude/tests/stable-roster.test.ts +++ b/packages/ui-backend-claude/tests/stable-roster.test.ts @@ -14,10 +14,11 @@ test("Claude complete tool schemas and native exclusions stay identical voice-fi const brain = mkdtempSync(join(tmpdir(), "odysseus-roster-")); const rosters: string[] = [], exclusions: string[] = []; try { - for (const mode of ["voice", "typed", "missing", "strict", "no-grant"] as const) { + for (const mode of ["voice", "typed", "missing", "strict", "no-grant", "unhosted"] as const) { const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { prompt: "Return to Ithaca", signal: new AbortController().signal, - bridge: (mode === "missing" ? {} : bridge()) as never, + bridge: (["missing", "unhosted"].includes(mode) ? {} : bridge()) as never, + ...(mode !== "unhosted" ? { applicationAuthority: "server" as const } : {}), ...(mode === "voice" ? { posture: "voice", enforceAllowedTools: true } : {}), noGrantSurface: mode === "voice" || mode === "no-grant", workerSandbox: { brain, profile: mode === "strict" ? "strict" : "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, @@ -31,7 +32,7 @@ test("Claude complete tool schemas and native exclusions stay identical voice-fi await client.connect(ct); const tools = (await client.listTools()).tools; for (const legacy of ["brain_add", "brain_update", "brain_archive"]) - expect(turn.options.disallowedTools?.includes(`mcp__brain__${legacy}`), `LEGACY_DEPLOYMENT_MEMBERSHIP_${mode}_${legacy}`).toBe(mode !== "missing"); + expect(turn.options.disallowedTools?.includes(`mcp__brain__${legacy}`), `LEGACY_DEPLOYMENT_MEMBERSHIP_${mode}_${legacy}`).toBe(mode !== "unhosted"); for (const name of [...refused, "brain_read_base", "brain_add", "show_block", "turn_temp"]) expect(tools.some(tool => tool.name === name), `FULL_ROSTER_${mode}_${name}`).toBe(true); rosters.push(JSON.stringify(tools)); exclusions.push(JSON.stringify(turn.options.disallowedTools?.filter(name => ["Write", "Edit", "NotebookEdit"].includes(name)))); diff --git a/packages/ui-sdk/src/server/backend.ts b/packages/ui-sdk/src/server/backend.ts index 10ab9214b4..36877cca11 100644 --- a/packages/ui-sdk/src/server/backend.ts +++ b/packages/ui-sdk/src/server/backend.ts @@ -502,6 +502,12 @@ export interface StartTurnRequest { /** Host remaining turn list-price budget; Claude uses its SDK backstop. */ maxBudgetUsd?: number; bridge: BackendBridge; + /** + * Trusted deployment fact, stable across a hosted session even when a turn + * temporarily lacks applyBrain. Never supplied by a client/model, and never + * a grant. Omitted direct consumers retain their project MCP writer roster. + */ + applicationAuthority?: "server"; /** * What the reader is on, as the browser measured it at send time. Absent * when the client is older or headless. Backends that can vary their system diff --git a/packages/ui-server/src/ws/run-session.ts b/packages/ui-server/src/ws/run-session.ts index afa54ff00c..7d1e828507 100644 --- a/packages/ui-server/src/ws/run-session.ts +++ b/packages/ui-server/src/ws/run-session.ts @@ -607,6 +607,7 @@ async function runRetainedSession( enteredBackend = true; turn.backendStarted = true; await backend.startTurn({ + applicationAuthority: "server", ...(governedHold?.startupView ? { workerStartupView: governedHold.startupView } : {}), ...(host.scope && governedHold ? { workerStarted: worker => host.scope!.workerStarted(scopeTurnId, worker) } : {}), ...(governedHold ? { workerSandbox: { ...governedHold.sandbox, noteFacts: { diff --git a/packages/ui-server/tests/brain-application-adapters.test.ts b/packages/ui-server/tests/brain-application-adapters.test.ts index 3dd7b76bf0..1fa3ceff1a 100644 --- a/packages/ui-server/tests/brain-application-adapters.test.ts +++ b/packages/ui-server/tests/brain-application-adapters.test.ts @@ -181,7 +181,7 @@ test("the actual Claude turn options shadow project writers and expose server wr } expect(a.options!.allowedTools).toContain("mcp__brain-ui__brain_add"); expect(a.options!.allowedTools).not.toContain("mcp__brain__brain_add"); - expect(a.options!.disallowedTools).toContain("Write"); + expect(a.options!.disallowedTools).not.toContain("Write"); }); test("hosted voice retains capture/update and refuses archive, raw writes and grants", async () => { diff --git a/packages/ui-server/tests/ws-client-environment.test.ts b/packages/ui-server/tests/ws-client-environment.test.ts index 833f770a78..bf7dc0d206 100644 --- a/packages/ui-server/tests/ws-client-environment.test.ts +++ b/packages/ui-server/tests/ws-client-environment.test.ts @@ -58,12 +58,13 @@ const desktop: ClientEnvironment = { formFactor: "desktop", viewportWidth: 750 } function backendCapturing( seen: Array, - opts: { sessionId: string; hold?: { until: Promise } } = { sessionId: "s1" } + opts: { sessionId: string; hold?: { until: Promise }; requests?: StartTurnRequest[] } = { sessionId: "s1" } ) { return makeFakeBackend({ id: "claude", async startTurn(request: StartTurnRequest) { seen.push(request.client); + opts.requests?.push(request); request.bridge.emit({ type: "session_info", sessionId: opts.sessionId, @@ -84,6 +85,14 @@ function backendCapturing( } describe("client environment over the websocket", () => { + test("the host declares stable application ownership on backend requests", async () => { + const requests: StartTurnRequest[] = []; + setBackendsForTests([backendCapturing([], { sessionId: "odysseus-roster", requests })], "claude"); + await handleClientMessage({ send() {} }, { type: "chat_message", text: "Check the voyage" }); + await waitFor(() => requests.length === 1); + expect(requests[0]!.applicationAuthority, "HOST_OWNS_APPLICATION_ROSTER").toBe("server"); + }); + test("reaches startTurn on a new session", async () => { const seen: Array = []; setBackendsForTests([backendCapturing(seen)], "claude"); From 9cb3cac47726de9d76661efc56e61a46aea954bf Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:49:43 +0200 Subject: [PATCH 4/7] fix(claude): retain independently available inbox handlers --- packages/ui-backend-claude/src/sdk-options.ts | 6 +++--- .../tests/fixtures/stable-roster-probe.ts | 7 ++++--- .../ui-backend-claude/tests/stable-roster-runtime.test.ts | 8 ++++---- packages/ui-backend-claude/tests/stable-roster.test.ts | 5 +++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index d572430545..3fa0ff3da3 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -147,8 +147,8 @@ function assembleClaudeSdkTurn( // file/tool access the model already has. if (queryActivity) allowed.push(QUERY_ACTIVITY_TOOL_NAME); if (turnTempTool) allowed.push(TURN_TEMP_TOOL_NAME); - if (req.bridge.inboxList) allowed.push(visibleToolName("inbox_list", "claude"), visibleToolName("inbox_get", "claude")); - if (req.bridge.inboxList && !req.noGrantSurface && req.posture !== "voice") allowed.push(visibleToolName("inbox_add", "claude"), visibleToolName("resolve_action", "claude"), visibleToolName("complete_share", "claude")); + for (const [name, handler] of [["inbox_list", "inboxList"], ["inbox_get", "inboxGet"]] as const) if (req.bridge[handler]) allowed.push(visibleToolName(name, "claude")); + if (!req.noGrantSurface && req.posture !== "voice") for (const [name, handler] of [["inbox_add", "inboxAdd"], ["resolve_action", "resolveAction"], ["complete_share", "completeShare"]] as const) if (req.bridge[handler]) allowed.push(visibleToolName(name, "claude")); // No side effect and no host handler: the block is part of the answer, and // an approval card for drawing a table would be the surface asking // permission to answer. @@ -322,7 +322,7 @@ function assembleClaudeSdkTurn( getLocation, requestMask, queryActivity, - ...(req.bridge.inboxList ? { inbox: req.bridge } : {}), + ...(!req.autonomous || req.bridge.inboxList ? { inbox: req.bridge } : {}), ...(turnTempTool ? { turnTemp: createTurnTempTool(turnTemp, event => { try { req.bridge.activity?.(event); } catch { /* Observability must not fail the observed turn. */ } }) } : {}), diff --git a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts index 1d55680949..753ad9d834 100644 --- a/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts +++ b/packages/ui-backend-claude/tests/fixtures/stable-roster-probe.ts @@ -50,7 +50,7 @@ const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) if (pendingRead) { pendingRead = false; return response([{ type: "tool_use", id: "toolu_loom_read", name: "Read", input: { file_path: join(brain, "notes/loom.ipynb") } }]); } if (!pending) return response([]); pending = false; - const selected = active.mode === "unhosted" ? ["mcp__brain__brain_add"] : active.mode === "typed" ? ["ask_user_list"] : active.mode === "strict" ? names.filter(n => !n.startsWith("mcp") && /^[A-Z]/.test(n)) : active.mode === "voice" ? names : names.filter(n => !/^[A-Z]/.test(n)); + const selected = active.mode === "unhosted" ? ["mcp__brain__brain_add"] : active.mode === "partial" ? ["inbox_add"] : active.mode === "typed" ? ["ask_user_list"] : active.mode === "strict" ? names.filter(n => !n.startsWith("mcp") && /^[A-Z]/.test(n)) : active.mode === "voice" ? names : names.filter(n => !/^[A-Z]/.test(n)); if (active.mode === "missing") selected.push(...Object.keys(missingInputs)); return response(selected.map((name, index) => ({ type: "tool_use", id: `toolu_raft_${captures.length}_${index}`, name: /^[A-Z]/.test(name) || name.startsWith("mcp__") ? name : `mcp__brain-ui__${name}`, input: inputs[name] ?? missingInputs[name] ?? {} }))); @@ -65,8 +65,8 @@ function response(calls: unknown[]) { } let sessionId: string | undefined; try { - for (const mode of ["typed", "voice", "strict", "voice", "typed", "missing", "no-grant", "unhosted", "unhosted"]) { - if ([3, 7].includes(captures.length)) sessionId = undefined; + for (const mode of ["typed", "voice", "strict", "voice", "typed", "missing", "no-grant", "partial", "unhosted", "unhosted"]) { + if ([3, 8].includes(captures.length)) sessionId = undefined; active = { mode, tools: undefined, results: [], denials: [], calls: [], cards: 0 }; captures.push(active); pending = true; pendingRead = true; const controller = new AbortController(); const sandbox = prepareFixtureWorkerSandbox(brain, { home, profile: mode === "strict" ? "strict" : "governed", areas: [] }); @@ -74,6 +74,7 @@ try { const unavailable = (name: string) => async () => { active.calls.push(name); throw new Error("PRIVILEGED_HANDLER_EXECUTED"); }; const bridge: any = { emit() {}, checkpointPermission() {}, async requestPermission() { active.cards++; return { behavior: "allow" }; } }; if (!["missing", "unhosted"].includes(mode)) for (const name of ["askUserList", "askUserRank", "askUserForm", "requestMask", "proposePolicyChange", "inboxList", "inboxGet", "inboxAdd", "resolveAction", "completeShare", "applyBrain", "readBrainBase"]) bridge[name] = unavailable(name); + if (mode === "partial") { delete bridge.inboxList; bridge.inboxAdd = async () => { active.calls.push("inboxAdd"); return { itemId: "raft" }; }; } if (mode === "typed") bridge.askUserList = async () => { active.calls.push("askUserList"); return { answers: { raft: "Keep" }, skipped: [] }; }; const lock = createTurnLockBinding({ turn: { ended: false, pendingReleases: new Map() }, writeLock: createKeyedLock(), lockWaitMs: 1000, log() {} }); const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { diff --git a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts index b2851d2aa9..1f0c3e762b 100644 --- a/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts +++ b/packages/ui-backend-claude/tests/stable-roster-runtime.test.ts @@ -16,13 +16,13 @@ test("installed Claude keeps request tools fixed and refuses every unavailable t const bridgeNames = ["request_image_mask", "ask_user_list", "ask_user_rank", "ask_user_form", "propose_policy_change", "inbox_add", "resolve_action", "complete_share"].map(name => `mcp__brain-ui__${name}`); const natives = ["Write", "Edit", "NotebookEdit"]; const missing = ["ask_user", "get_current_location", "query_activity", "brain_read_base", "brain_add", "brain_update", "brain_archive", "write_file", "edit_file", "apply_staged_changes"].map(name => `mcp__brain-ui__${name}`); - expect(captures).toHaveLength(9); + expect(captures).toHaveLength(10); expect(new Set(captures.slice(0, 3).map((c: any) => c.sessionId)).size).toBe(1); - expect(new Set(captures.slice(3, 7).map((c: any) => c.sessionId)).size).toBe(1); + expect(new Set(captures.slice(3, 8).map((c: any) => c.sessionId)).size).toBe(1); expect(captures[0].sessionId).not.toBe(captures[3].sessionId); for (const capture of captures) { if (capture.mode === "unhosted") { - expect(JSON.stringify(capture.tools), "BYTE_IDENTICAL_UNHOSTED_TOOLS").toBe(JSON.stringify(captures[7].tools)); + expect(JSON.stringify(capture.tools), "BYTE_IDENTICAL_UNHOSTED_TOOLS").toBe(JSON.stringify(captures[8].tools)); for (const name of ["brain_add", "brain_update", "brain_archive"]) expect(capture.tools.some((tool: any) => tool.name === `mcp__brain__${name}`), `UNHOSTED_LEGACY_ROSTER_${name}`).toBe(true); expect(capture.cards).toBe(0); expect(capture.results[0].is_error).not.toBe(true); @@ -31,7 +31,7 @@ test("installed Claude keeps request tools fixed and refuses every unavailable t } expect(JSON.stringify(capture.tools), `BYTE_IDENTICAL_PROVIDER_TOOLS_${capture.mode}`).toBe(JSON.stringify(captures[0].tools)); expect(capture.cards, `NO_PERMISSION_CARDS_${capture.mode}`).toBe(0); - if (capture.mode === "typed") { expect(capture.calls).toEqual(["askUserList"]); expect(capture.results[0].is_error).not.toBe(true); continue; } + if (["typed", "partial"].includes(capture.mode)) { expect(capture.calls, `TYPED_AVAILABLE_HANDLER_${capture.mode}`).toEqual([capture.mode === "partial" ? "inboxAdd" : "askUserList"]); expect(capture.results[0].is_error).not.toBe(true); continue; } const expected = capture.mode === "strict" ? natives : capture.mode === "voice" ? [...bridgeNames, ...natives] : capture.mode === "missing" ? [...bridgeNames, ...missing] : bridgeNames; for (const name of expected) expect(capture.denials, `RUNTIME_HOOK_REFUSAL_${capture.mode}_${name}: ${JSON.stringify(capture.results)}`).toContain(name); expect(capture.calls, `NO_PRIVILEGED_HANDLER_${capture.mode}`).toEqual([]); diff --git a/packages/ui-backend-claude/tests/stable-roster.test.ts b/packages/ui-backend-claude/tests/stable-roster.test.ts index 0d0fcac649..45122a2c5a 100644 --- a/packages/ui-backend-claude/tests/stable-roster.test.ts +++ b/packages/ui-backend-claude/tests/stable-roster.test.ts @@ -14,10 +14,10 @@ test("Claude complete tool schemas and native exclusions stay identical voice-fi const brain = mkdtempSync(join(tmpdir(), "odysseus-roster-")); const rosters: string[] = [], exclusions: string[] = []; try { - for (const mode of ["voice", "typed", "missing", "strict", "no-grant", "unhosted"] as const) { + for (const mode of ["voice", "typed", "missing", "strict", "no-grant", "unhosted", "partial"] as const) { const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { prompt: "Return to Ithaca", signal: new AbortController().signal, - bridge: (["missing", "unhosted"].includes(mode) ? {} : bridge()) as never, + bridge: (["missing", "unhosted"].includes(mode) ? {} : mode === "partial" ? { ...bridge(), inboxList: undefined } : bridge()) as never, ...(mode !== "unhosted" ? { applicationAuthority: "server" as const } : {}), ...(mode === "voice" ? { posture: "voice", enforceAllowedTools: true } : {}), noGrantSurface: mode === "voice" || mode === "no-grant", @@ -31,6 +31,7 @@ test("Claude complete tool schemas and native exclusions stay identical voice-fi await (turn.options.mcpServers!["brain-ui"] as any).instance.connect(st); await client.connect(ct); const tools = (await client.listTools()).tools; + if (mode === "partial") expect(turn.options.allowedTools, "PARTIAL_INBOX_HANDLER_AUTO_ALLOWED").toContain("mcp__brain-ui__inbox_add"); for (const legacy of ["brain_add", "brain_update", "brain_archive"]) expect(turn.options.disallowedTools?.includes(`mcp__brain__${legacy}`), `LEGACY_DEPLOYMENT_MEMBERSHIP_${mode}_${legacy}`).toBe(mode !== "unhosted"); for (const name of [...refused, "brain_read_base", "brain_add", "show_block", "turn_temp"]) From 04b6d2765cea8c5da0a78d7abbd140bc168530fe Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 13:16:08 +0200 Subject: [PATCH 5/7] test(backends): assert retained roster and effective policy refusals --- tests/policy-write-boundary.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/policy-write-boundary.test.ts b/tests/policy-write-boundary.test.ts index db90021e05..c6a5cb9cde 100644 --- a/tests/policy-write-boundary.test.ts +++ b/tests/policy-write-boundary.test.ts @@ -138,8 +138,8 @@ for (const adapter of ["claude", "pi"] as const) describe(`${adapter} policy-wri expect(cli.result.output, "brain CLI write refuses visibly; nothing is staged").toContain("read_only_brain"); expect(r.added, "no capture was staged or written").toEqual([]); if (adapter === "claude") { - // Hosted turns withhold the raw file tools; the subagent's shell is confined too. - expect(r.receipt.unavailable.sort()).toEqual(["Edit", "NotebookEdit", "Write"]); + // Hosted turns retain raw file definitions and refuse execution; the subagent's shell is confined too. + expect(r.receipt.unavailable, "raw tools remain registered").toEqual([]); for (const name of ["Write", "Edit", "NotebookEdit"]) for (const c of calls(r, name)) expect(c.result.isError).toBe(true); const sub = shells.filter(c => c.subagent); expect(sub.length, "the subagent issued its own shell write").toBe(1); @@ -195,7 +195,7 @@ for (const adapter of ["claude", "pi"] as const) describe(`${adapter} policy-wri passed.push(`${adapter}:race`); }, TIMEOUT); - test("voice: the narrower membership cannot grant or write policy; an authorized capture still lands", async () => { + test("voice: the narrower authority cannot grant or write policy; an authorized capture still lands", async () => { const r = await probe(adapter, "voice"); if (adapter === "pi") { // pi declares no voice posture: refused before any writer initializes. @@ -213,7 +213,10 @@ for (const adapter of ["claude", "pi"] as const) describe(`${adapter} policy-wri expect(r.added[0]).toMatch(/^notes\/.+\.md$/); expect(r.receipt.frames.filter((f: any) => f.type === "tool_approval_request"), "voice never surfaces a grant").toEqual([]); expect(calls(r, "Bash")[0]!.result.output).toContain("cannot be approved here and did not run"); - expect(r.receipt.unavailable.sort(), "raw writes and the mask editor are outside voice").toEqual(["Write", "mcp__brain-ui__request_image_mask"]); + expect(r.receipt.unavailable, "voice retains registered definitions").toEqual([]); + for (const name of ["Write", "mcp__brain-ui__request_image_mask"]) { + for (const c of calls(r, name)) expect(c.result.isError, `${name} is refused on voice`).toBe(true); + } expect(code(calls(r, route("write_file"))[0]!.result.output), "raw write is outside the voice membership").toBe("membership_denied"); expect(code(calls(r, route("brain_update"))[0]!.result.output)).toBe("policy_denied"); const adds = calls(r, route("brain_add")); @@ -289,10 +292,11 @@ test("two governed Pi sessions hold the same server Git lock through commit", as expect(r.after.policy).toBe(POLICY); }, TIMEOUT); -test("governed voice retains its narrow roster and captures through the authoritative route", async () => { +test("governed voice retains narrow authority and captures through the authoritative route", async () => { const r = await probe("claude", "governed-voice"); expect(calls(r, "Bash")[0]!.result.output).toContain("cannot be approved here and did not run"); - expect(r.receipt.unavailable).toContain("Write"); + expect(r.receipt.unavailable).not.toContain("Write"); + for (const c of calls(r, "Write")) expect(c.result.isError, "governed voice refuses native writes").toBe(true); expect(r.receipt.posture).toBe("voice"); const capture = calls(r, "mcp__brain-ui__brain_add", input => input.title === "Voice raft")[0]!; expect(capture.result.isError, capture.result.output).toBe(false); From 157eba50a7295932cea52cc724bc0c14412cdf85 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 13:43:44 +0200 Subject: [PATCH 6/7] test(claude): exercise native approval controls on governed turns --- .../tests/autonomous-yield.test.ts | 4 +++- .../tests/backend-contract.test.ts | 7 ++++++- packages/ui-backend-claude/tests/backend.test.ts | 15 ++++++++++----- tests/installed-turn-surface.test.ts | 4 ++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/ui-backend-claude/tests/autonomous-yield.test.ts b/packages/ui-backend-claude/tests/autonomous-yield.test.ts index 4498836ca1..894b8a0f4d 100644 --- a/packages/ui-backend-claude/tests/autonomous-yield.test.ts +++ b/packages/ui-backend-claude/tests/autonomous-yield.test.ts @@ -44,7 +44,9 @@ test("Claude's actual hook/runner yields a long autonomous write and releases it autonomous: { origin: "autonomous", persistence: "none", allowedTools: ["Write"], systemPromptAppend: "", onYield: () => { signals++; expect(lock.locked).toBe(true); } } }); await acquired; - const foreground = backend.startTurn(request); + const foreground = backend.startTurn({ ...request, + workerSandbox: { brain: root, profile: "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, + }); await Bun.sleep(0); try { time = 19_999; diff --git a/packages/ui-backend-claude/tests/backend-contract.test.ts b/packages/ui-backend-claude/tests/backend-contract.test.ts index afc12150bf..a227fa511e 100644 --- a/packages/ui-backend-claude/tests/backend-contract.test.ts +++ b/packages/ui-backend-claude/tests/backend-contract.test.ts @@ -211,8 +211,13 @@ function claudePermissionProbe(scenario: PermissionScenario): PermissionProbe { yield { type: "result", subtype: "success", session_id: "permission-probe", total_cost_usd: 0, duration_ms: 1, num_turns: 1 }; })(); }) as unknown as typeof query; + const backend = createClaudeBackend({ brainPath, queryFn, allowedTools: scenario === "command" ? [toolName] : [], log: () => {} }); return { - backend: createClaudeBackend({ brainPath, queryFn, allowedTools: scenario === "command" ? [toolName] : [], log: () => {} }), + // Exercise native permission decisions on a turn where writes are permitted; + // strict refusal is independently covered by the real runtime controls. + backend: { ...backend, startTurn: req => backend.startTurn({ ...req, ...(scenario === "mutation" ? { + workerSandbox: { brain: brainPath, profile: "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, + } : {}) }) }, toolName, toolUseId: "probe-tool", starts: () => starts, diff --git a/packages/ui-backend-claude/tests/backend.test.ts b/packages/ui-backend-claude/tests/backend.test.ts index 74cffe4df4..c3d9dbe13d 100644 --- a/packages/ui-backend-claude/tests/backend.test.ts +++ b/packages/ui-backend-claude/tests/backend.test.ts @@ -65,6 +65,11 @@ function makeReq( return { prompt: "hi", signal, bridge, ...extra }; } +// Lock lifecycle controls must reach a permitted native write, not strict refusal. +const governedNativeTurn: Partial = { + workerSandbox: { brain: "/brain", profile: "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, +}; + const tick = () => new Promise((r) => setTimeout(r, 0)); /** Spin until `pred` holds, so a test never races a background acquire/emit. */ @@ -926,7 +931,7 @@ describe("writeLock", () => { const backend = createClaudeBackend({ brainPath: "/brain", queryFn, writeLock }); const b1 = makeBridge(); - await backend.startTurn(makeReq(b1.bridge, new AbortController().signal)); + await backend.startTurn(makeReq(b1.bridge, new AbortController().signal, governedNativeTurn)); expect(denied).toBe(true); expect(heldBeforeDenial).toEqual(["path:/brain/notes/a.md"]); @@ -982,7 +987,7 @@ describe("writeLock", () => { const backend = createClaudeBackend({ brainPath: "/brain", queryFn, writeLock }); const b1 = makeBridge(); - const turn1 = backend.startTurn(makeReq(b1.bridge, abort1.signal)); + const turn1 = backend.startTurn(makeReq(b1.bridge, abort1.signal, governedNativeTurn)); await until(() => t1Resolved); expect(writeLock.locked).toBe(true); @@ -993,7 +998,7 @@ describe("writeLock", () => { // A later mutating turn acquires cleanly, proving the lock was freed. const b2 = makeBridge(); - const turn2 = backend.startTurn(makeReq(b2.bridge, new AbortController().signal)); + const turn2 = backend.startTurn(makeReq(b2.bridge, new AbortController().signal, governedNativeTurn)); await turn2; expect(b2.frames.some((f) => f.type === "result")).toBe(true); expect(writeLock.locked).toBe(false); @@ -1209,14 +1214,14 @@ describe("lockKeyForTool — the serialization policy", () => { const backend = createClaudeBackend({ brainPath: "/brain", queryFn, writeLock }); const b1 = makeBridge(); - const turn1 = backend.startTurn(makeReq(b1.bridge, new AbortController().signal)); + const turn1 = backend.startTurn(makeReq(b1.bridge, new AbortController().signal, governedNativeTurn)); await until(() => t1Resolved); expect(writeLock.heldKeys).toEqual(["path:/brain/notes/a.md"]); // The second write targets a different file: it must NOT park behind the // first — this is the single-session-collapse regression guard. const b2 = makeBridge(); - const turn2 = backend.startTurn(makeReq(b2.bridge, new AbortController().signal)); + const turn2 = backend.startTurn(makeReq(b2.bridge, new AbortController().signal, governedNativeTurn)); await until(() => t2Resolved); expect(writeLock.heldKeys.sort()).toEqual([ "path:/brain/notes/a.md", diff --git a/tests/installed-turn-surface.test.ts b/tests/installed-turn-surface.test.ts index c0a407a2b7..ab90f102b7 100644 --- a/tests/installed-turn-surface.test.ts +++ b/tests/installed-turn-surface.test.ts @@ -1,6 +1,6 @@ // The installed Claude CLI's serialisation of the shipped tool surface under // `createClaudeSdkTurn`'s production defaults: core brain tools stay deferred, -// the fourteen bridge tools (the five inbox tools among them) load eagerly with +// the complete stable bridge roster (including unavailable tools) loads eagerly with // their schemas, and the emitted skill listing reaches the request. Package // tests check only the MCP `_meta` stamp; this is the half where the CLI // honours it. Split verbatim from the turn-surface measurement test (#1386), @@ -19,7 +19,7 @@ test("actual installed CLI request preserves shipped deferred core tools, eager "mcp__brain__brain_add", "mcp__brain__brain_archive", "mcp__brain__brain_context", "mcp__brain__brain_graph", "mcp__brain__brain_list", "mcp__brain__brain_read", "mcp__brain__brain_search", "mcp__brain__brain_update", ]); - expect(capture.request.tools.filter(tool => tool.name.startsWith("mcp__brain-ui__"))).toHaveLength(14); + expect(capture.request.tools.filter(tool => tool.name.startsWith("mcp__brain-ui__"))).toHaveLength(22); for (const name of ["inbox_list", "inbox_get", "inbox_add", "resolve_action", "complete_share", "turn_temp"]) { const tool = capture.request.tools.find(tool => tool.name === `mcp__brain-ui__${name}`); expect(tool?.input_schema).toBeDefined(); From b5c02d94ddb2b9eb59c549f9edb26d10b3e19b4f Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 13:43:46 +0200 Subject: [PATCH 7/7] fix(pi): preserve autonomous permission checkpoints before denial --- packages/ui-backend-pi/src/worker-session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-backend-pi/src/worker-session.ts b/packages/ui-backend-pi/src/worker-session.ts index 97b4fe0451..a39c19d8a1 100644 --- a/packages/ui-backend-pi/src/worker-session.ts +++ b/packages/ui-backend-pi/src/worker-session.ts @@ -384,7 +384,7 @@ export async function openPiWorkerSession(backend: CreatePiBackendOptions, sessi } const application = Object.values(BRAIN_APPLICATION_TOOLS).includes(msg.toolName as any) && !(workerSandbox?.profile === "governed" && ["write_file", "edit_file"].includes(msg.toolName)); - if (application && !bridge.applyBrain) return { behavior: "deny", message: "No server application route is available in this turn." }; + if (!turnContext.autonomous && application && !bridge.applyBrain) return { behavior: "deny", message: "No server application route is available in this turn." }; if (application && bridge.applyBrain) { // Exact application authorization belongs to the server route. return { behavior: "allow" };