From b4a458a6690019614b23ed9e62c43be7f2c270b5 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:11:22 +0200 Subject: [PATCH 1/4] CONTRACT: fix(backends): restore governed native write authority --- .changeset/quiet-native-voyage.md | 5 + AGENTS.md | 2 +- docs/decisions/README.md | 2 +- docs/decisions/async-collaboration.md | 2 +- .../decisions/claude-2.1.287-compatibility.md | 6 +- docs/decisions/claude-code-runtime.md | 14 +-- .../design-kit/backend-measurements.md | 2 +- docs/decisions/governed-writable-workers.md | 18 ++- docs/decisions/policy-write-boundary.md | 2 +- docs/decisions/voice-permission.md | 35 ++++-- docs/integration-contract/mcp.md | 10 +- .../plans/async-collaboration-requirements.md | 6 +- docs/plans/async-collaboration.md | 8 +- docs/plans/policy-write-boundary.md | 2 +- .../ui-backend-claude/src/permission-hooks.ts | 12 ++ packages/ui-backend-claude/src/sdk-options.ts | 13 ++- .../tests/native-hosted-permission.test.ts | 38 +++++++ .../tests/native-hosted-runtime.test.ts | 107 ++++++++++++++++++ scripts/check-citations.ts | 2 +- 19 files changed, 245 insertions(+), 41 deletions(-) create mode 100644 .changeset/quiet-native-voyage.md create mode 100644 packages/ui-backend-claude/tests/native-hosted-permission.test.ts create mode 100644 packages/ui-backend-claude/tests/native-hosted-runtime.test.ts diff --git a/.changeset/quiet-native-voyage.md b/.changeset/quiet-native-voyage.md new file mode 100644 index 000000000..1ae8c9568 --- /dev/null +++ b/.changeset/quiet-native-voyage.md @@ -0,0 +1,5 @@ +--- +"@schlessera/brain-backend-claude": minor +--- + +Restore approval-free governed native writes and keep strict/voice native tools registered with enforced call-time refusals. diff --git a/AGENTS.md b/AGENTS.md index 9518f2726..e5ae139c3 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:188-211`), 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 5ab2d5e0a..af0755bff 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:188-211`) ``` - **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 85c641d9f..2bc172127 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-273`). 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 diff --git a/docs/decisions/claude-2.1.287-compatibility.md b/docs/decisions/claude-2.1.287-compatibility.md index bfd9ba308..d3fec3fc2 100644 --- a/docs/decisions/claude-2.1.287-compatibility.md +++ b/docs/decisions/claude-2.1.287-compatibility.md @@ -122,11 +122,11 @@ The property under test is: with `enforceAllowedTools: true`, a `Bash` call outside the turn's roster cannot execute unless the host bridge grants that 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`). +(`createClaudeSdkTurn`, `packages/ui-backend-claude/src/sdk-options.ts:85-210`). 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:188-211`) 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:541-544`). `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 aef745306..bc5c1b771 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:272`). 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:156-159`). 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:183-185`; `(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,7 +383,7 @@ 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:155-186` | 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. | @@ -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:156-159` already says three is what has been measured, not a closed set — and no probe can close that. ## Where the work goes @@ -455,7 +455,7 @@ into them. A turn's environment is the filtered agent environment plus the profile's additions (`export function turnEnv`, `packages/ui-backend-claude/src/sdk-options.ts:65-71`, `envSnapshot`, `packages/ui-backend-claude/src/config/env.ts:182-190`), handed to the SDK - whole (`sdkOptions.env = childEnv`, `sdk-options.ts:332`). A declared bearer-token profile clears both + whole (`sdkOptions.env = childEnv`, `sdk-options.ts:341`). A declared bearer-token profile clears both ambient credentials (`input.authTokenEnv !== undefined`, `profiles.ts:116-122`); a declared API-key profile sets the key on purpose (`input.apiKeyEnv !== undefined`, `profiles.ts:123-125`). - **Billing originally relied on ambient credential classification.** The @@ -603,7 +603,7 @@ subscription authenticates. environment is necessary, whether or not a subscription credential is present. It is not sufficient: the CLI also takes an API key from an `apiKeyHelper` in settings — the backend loads the brain repo's project - settings (`settingSources: ["project"]`, `sdk-options.ts:210`) — and from a stored Console login, reported + settings (`settingSources: ["project"]`, `sdk-options.ts:218`) — and from a stored Console login, reported as `/login managed key` (`sdk.d.ts:5585`). So the turn has to check which credential the CLI selected **before the prompt is sent**, and end the turn if it is not a subscription. The check reads the account from the SDK's diff --git a/docs/decisions/design-kit/backend-measurements.md b/docs/decisions/design-kit/backend-measurements.md index 50d1fba28..049a6e53b 100644 --- a/docs/decisions/design-kit/backend-measurements.md +++ b/docs/decisions/design-kit/backend-measurements.md @@ -39,7 +39,7 @@ and each closed before the measured runs: - **The brain has to live outside the operator's home directory, not only outside a checkout.** `settingSources: ["project"]`, - `packages/ui-backend-claude/src/sdk-options.ts:210`, makes the CLI walk up + `packages/ui-backend-claude/src/sdk-options.ts:218`, makes the CLI walk up from the cwd, and at every ancestor it reads `.claude/CLAUDE.md`, `.claude/skills/` and `.claude/agents/`. A brain anywhere under a home directory therefore loads `~/.claude/CLAUDE.md` as *project* instructions, diff --git a/docs/decisions/governed-writable-workers.md b/docs/decisions/governed-writable-workers.md index bda84efa8..24a0e91d2 100644 --- a/docs/decisions/governed-writable-workers.md +++ b/docs/decisions/governed-writable-workers.md @@ -126,7 +126,7 @@ What a turn can do is the intersection of the two. | Writable areas | The brain, plus #1626's named areas | #1626's named areas only (scratch, workspaces, turn temp, `$HOME` overlay) | Turn temp only | | Host root | Read-only, with runtime and home directories masked | Same as `governed` | An explicit read envelope | | Network | Host namespace until the egress monitor lands, then a private namespace with the egress proxy | Same as `governed` | Loopback plus the inference relay | -| Claude native `Write`/`Edit`/`NotebookEdit` | Available | Disallowed. The server tools are used instead. | Not in the roster | +| Claude native `Write`/`Edit`/`NotebookEdit` | Auto-allowed on typed turns; voice calls refuse | Registered but refused at call time. The server tools are used instead. | Not in the roster | | Server application tools | Available | Available | Proposal route only | | Journal | Yes | Yes. It only ever sees named-area and server-route changes. | No authoritative changes are possible | @@ -168,6 +168,18 @@ A voice turn therefore writes only through curated capture and update, now with a journal behind it. The writable mount gives voice no tool it lacked. Pi still refuses voice turns. +**2026-10-16 native-tool registration amendment (#2022, #2048 A).** +Hosted interactive Claude keeps native file-tool definitions registered across +`governed`, `strict` and voice turns to preserve provider prompt caching. +Governed typed `Write`/`Edit` (and configured `NotebookEdit`) retain their +SDK auto-allow, alongside existing server writers. Strict and voice natives +refuse in the mandatory PreToolUse hook and permission callback before any +handler, approval card or lock acquisition; settings and remembered grants +cannot override that refusal. Native stale-read checks, write locks, journals +and protected mounts still apply to governed typed execution. The autonomous +roster and authority do not change. This amends native availability language +above; #2048 owns the broader bridge-roster amendment. + ## The policy file **Location.** `context/policies/governance.md` in the brain. It is Markdown, @@ -970,7 +982,7 @@ classification: `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 + (`disallowedTools`, `packages/ui-backend-claude/src/sdk-options.ts:234`). 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 +1003,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:95`). 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 ed776d6b5..6f4a328d1 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-271`); 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 574972191..bf1615aee 100644 --- a/docs/decisions/voice-permission.md +++ b/docs/decisions/voice-permission.md @@ -55,7 +55,7 @@ functions**: `decideToolPermission` with `DEFAULT_ALLOWED_TOOLS` (`DEFAULT_ALLOWED_TOOLS`, `packages/ui-backend-claude/src/tool-policy.ts:27`), the six bridge tools the backend appends per turn (the `allowed.push` block in `createClaudeSdkTurn`, from `const allowed`, -`packages/ui-backend-claude/src/sdk-options.ts:127-152`) +`packages/ui-backend-claude/src/sdk-options.ts:131-160`) and `DEFAULT_CONFIRM_BASH_PATTERNS` (`DEFAULT_CONFIRM_BASH_PATTERNS`, `packages/ui-sdk/src/server/confirm-patterns.ts:55`). Tools that exist only in the recording harness and have no counterpart in this @@ -228,7 +228,7 @@ as its allowlist and declares both `enforceAllowedTools` and `noGrantSurface`. | `mcp__brain-ui__brain_update` | Edits an existing document. "Add this to my note about X" is the second most valuable eyes-free action, and the handler's shape is why it is safe enough to allow: it **never rewrites the body**, it only appends to it (`const appendTo`, `packages/core/src/lib/frontmatter-edit.ts:303-308`), so no prose can be lost. The exact bound on what it *can* destroy: the six frontmatter params are independent optionals on one call (`summary: z.string()`, `packages/core/src/mcp-server.ts:426-431`), applied independently (`input.summary !== undefined`, `packages/core/src/lib/ops/write.ts:88-100`), so **a single call can overwrite all six** — and `tags` replaces the whole tag list rather than merging into it (the terminal MCP accepts a comma-separated string, while the hosted route accepts a string array; `input.tags !== undefined`, `packages/core/src/lib/ops/write.ts:91`), while `deadline` and `next_review` take `""` as *delete the field* (`input.deadline !== undefined`, `packages/core/src/lib/ops/write.ts:92-99`). `updated` is bumped unconditionally (`updates.updated = updated`, `packages/core/src/lib/ops/write.ts:115`). None of it is checkpointed, so git recovers a prior value only if the document was committed. The claim this row rests on is therefore "loses no prose, and at most the six declared frontmatter fields, recoverable only if committed" — not "recoverable". `status` is the field that matters and it is handled separately below. | | `Read`, `Glob`, `Grep` | Read-only over the brain repo, for the questions the brain tools do not cover. No mutation, no egress. | | `WebSearch`, `WebFetch` | Read-only egress. Kept, with the exposure stated below. | -| the bridge tools, minus the mask editor | `ask_user`, `get_current_location`, `query_activity`, `show_block`, `turn_temp` are auto-allowed today and none of them is a permission decision (the same `allowed.push` block, from `const allowed`, `sdk-options.ts:127-152`). `request_image_mask` needs the user to paint a region, so it needs eyes; it is out. | +| the bridge tools, minus the mask editor | `ask_user`, `get_current_location`, `query_activity`, `show_block`, `turn_temp` are auto-allowed today and none of them is a permission decision (the same `allowed.push` block, from `const allowed`, `sdk-options.ts:131-160`). `request_image_mask` needs the user to paint a region, so it needs eyes; it is out. | Module tools are admitted only by exact name, never by their own annotations, as [module-mcp-tools.md §6](module-mcp-tools.md#6-annotations-and-permissions) @@ -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:280-290`) 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 @@ -487,8 +487,8 @@ to a turn — `InferenceProfile.allowedTools` (`allowedTools?: string[]`, `packages/ui-backend-claude/src/profiles.ts:27`) and `ClaudeBackendOptions.allowedTools` (`Backend-wide tool allowlist`, `options.ts:50-51`), resolved into the SDK's `allowedTools` per turn (the -`allowed` array, from `const allowed`, `sdk-options.ts:127`, and what it -becomes, `allowedTools: allowed`, `:223`). The voice posture is one +`allowed` array, from `const allowed`, `sdk-options.ts:131`, and what it +becomes, `allowedTools: allowed`, `:231`). The voice posture is one named entry in that mechanism. **#51's U15 originally chose availability control for the same bypass reason.** @@ -582,7 +582,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:509-528`). 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 +599,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:188-211`) — 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 +619,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:155-186`, 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 +672,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:223-225`, and the call in `mutatingHook` that reads it, +`decideToolPermission`, `:289-296`). 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 @@ -816,3 +816,18 @@ capture and append-update remain permitted, an archiving update remains denied, and archive, raw writes, staged application and speech grants remain excluded. The server validates current authority and exact effects independently of runtime shortcuts. Ordinary permitted raw edits use the same route with existing approvals. + + +## Native registration amendment — 2026-10-16 (#2022, #2048 A) + +The maintainer selected a stable deployment-configured tool roster to preserve +provider prompt caching across posture/profile changes. For hosted interactive +Claude native `Write`/`Edit`/`NotebookEdit`, registration therefore no longer +represents authority: strict and voice turns keep the definitions but refuse +calls in PreToolUse before execution or an approval card, including when a +settings hook or remembered grant would otherwise allow them. Governed typed +natives remain auto-allowed under stale-read, write-lock and protected-path +controls. Autonomous registration and authority are unchanged. This supersedes +this record's preference for removing those native definitions; the historical +bypass measurements and voice-never-grants rule remain binding. #2048 covers +the broader bridge-tool stable-roster implementation. diff --git a/docs/integration-contract/mcp.md b/docs/integration-contract/mcp.md index 45d8f18a9..8358c7682 100644 --- a/docs/integration-contract/mcp.md +++ b/docs/integration-contract/mcp.md @@ -66,8 +66,14 @@ Hosted turns route authoritative Markdown through the server-owned application boundary. Standalone terminal `brain mcp` names and schemas above are unchanged. Claude registers the tools below under `mcp__brain-ui__`; pi uses the unprefixed names. Hosted Claude disallows project `mcp__brain__brain_add`, -`mcp__brain__brain_update` and `mcp__brain__brain_archive`, plus built-in raw -writers; its server tools apply the validated effects instead. +`mcp__brain__brain_update` and `mcp__brain__brain_archive`. +Hosted interactive Claude keeps native `Write`/`Edit`/`NotebookEdit` registered +across governed, strict and voice turns. Governed typed natives are auto-allowed +when configured, under existing stale-read, write-lock, journaling and protected +mount controls. Strict and voice native calls refuse at call time before +execution or an approval card, including with settings or remembered allows; +server application tools retain their existing per-turn authority. Autonomous +registration and authority are unchanged (#2022, #2048 A). | Tool | Exact input (unknown keys refuse) | | --- | --- | diff --git a/docs/plans/async-collaboration-requirements.md b/docs/plans/async-collaboration-requirements.md index ad0d90cf0..16361257b 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:188-211`). 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-273`). 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-273`), 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 1b4a0e373..3a1c01e26 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:188-211`). 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,11 +137,11 @@ 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:188-211`) 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-273`). 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, @@ -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-273`)) - 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 50cc92c44..48ca64356 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-265`). 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)). diff --git a/packages/ui-backend-claude/src/permission-hooks.ts b/packages/ui-backend-claude/src/permission-hooks.ts index a3a18fe31..c46cb26c7 100644 --- a/packages/ui-backend-claude/src/permission-hooks.ts +++ b/packages/ui-backend-claude/src/permission-hooks.ts @@ -40,6 +40,8 @@ function toPermissionResult( export function createPermissionWiring(options: { req: StartTurnRequest; + /** Hosted strict/voice natives stay registered but cannot gain execution authority. */ + denyNativeWrites?: boolean; allowedTools: readonly string[]; confirmPatterns: readonly RegExp[]; brainPath: string; @@ -56,6 +58,10 @@ export function createPermissionWiring(options: { observeFile?: (path: string) => ObservedWorkerFile | undefined; }): Pick { const { req, allowedTools, brainPath, turnLock, childEnv, log } = options; + const nativeAuthorityRefusal = (name: string): string | undefined => + options.denyNativeWrites && ["Write", "Edit", "NotebookEdit"].includes(name) + ? "Native file writes are unavailable in this turn's profile or voice posture. Use the permitted server application tools instead." + : undefined; const fileVersions = options.fileVersions ?? new NativeFileVersions(); const legacyObserve = req.workerSandbox?.profile === "governed" ? createWorkerFileObserver({ brain: brainPath, configurationRoot: brainPath, areas: [], maskPaths: req.workerSandbox.maskPaths }) : undefined; const filePath = (toolName: string, input: unknown): string | undefined => { @@ -254,6 +260,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 nativeDenied = nativeAuthorityRefusal(toolName); + if (nativeDenied) return { behavior: "deny", message: nativeDenied }; const denied = denyCommand(toolName, input, opts.toolUseID); if (denied) return { behavior: "deny", message: denied }; if (routed(toolName)) return { behavior: "allow" }; @@ -343,6 +351,10 @@ export function createPermissionWiring(options: { return { continue: true }; } if (req.signal.aborted) return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Turn cancelled before tool admission." } }; + const nativeDenied = nativeAuthorityRefusal(hookInput.tool_name); + if (nativeDenied) return { continue: true, hookSpecificOutput: { + hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: nativeDenied, + } }; 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 } }; if (routed(hookInput.tool_name)) return { continue: true }; diff --git a/packages/ui-backend-claude/src/sdk-options.ts b/packages/ui-backend-claude/src/sdk-options.ts index ee3869b6f..5784f6c21 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -124,7 +124,15 @@ function assembleClaudeSdkTurn( 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", }; - const allowed = [...new Set(options.allowedTools.map(name => hosted ? (migrations[name] ?? name) : name))]; + // Registration is stable across interactive profiles/postures (#2048 A). + // Native writers keep their auto-allow on governed typed turns; the hook + // refuses them on strict/voice turns before any settings or remembered grant. + // Keep server writers alongside the natives, with their existing authority. + const allowed = [...new Set(options.allowedTools.flatMap(name => { + if (!hosted) return [name]; + const migrated = migrations[name] ?? name; + return !req.autonomous && (name === "Write" || name === "Edit") ? [name, migrated] : [migrated]; + }))]; if (req.bridge.readBrainBase) allowed.push("mcp__brain-ui__brain_read_base"); // Application permission lives in the server. These are callable tools, // never authority: every executor goes through the server-owned route. @@ -225,9 +233,10 @@ function assembleClaudeSdkTurn( // 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"] : [])], + ] : []), ...(hosted && req.autonomous && (sandbox.profile !== "governed" || req.posture === "voice") ? ["Write", "Edit", "NotebookEdit"] : [])], ...createPermissionWiring({ req, + denyNativeWrites: hosted && !req.autonomous && (sandbox.profile !== "governed" || req.posture === "voice"), allowedTools: allowed, confirmPatterns, brainPath: backend.brainPath, diff --git a/packages/ui-backend-claude/tests/native-hosted-permission.test.ts b/packages/ui-backend-claude/tests/native-hosted-permission.test.ts new file mode 100644 index 000000000..5b3b6386e --- /dev/null +++ b/packages/ui-backend-claude/tests/native-hosted-permission.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createClaudeSdkTurn } from "../src/sdk-options.js"; +import { createTurnLockBinding } from "../src/turn-lock.js"; +import { createKeyedLock } from "@schlessera/brain-ui-sdk/server"; + +for (const profile of ["governed", "strict"] as const) for (const posture of [undefined, "voice"] as const) { + test(`hosted ${profile} ${posture ?? "typed"} preserves native registration and enforces authority`, async () => { + const brain = mkdtempSync(join(tmpdir(), "odysseus-native-permission-")); + let cards = 0; + const lock = createTurnLockBinding({ turn: { ended: false, pendingReleases: new Map() }, writeLock: createKeyedLock(), lockWaitMs: 1000, log: () => {} }); + const controller = new AbortController(); + const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, + req: { prompt: "Return to Ithaca.", signal: controller.signal, posture, + workerSandbox: { brain, profile, restricted: false, areas: [], scratchAliasesRemoved: [] }, + bridge: { emit: () => {}, requestPermission: async () => { cards++; return { behavior: "allow" }; }, applyBrain: async () => ({}) } } as never, + profile: { requiredEnvKeys: [], buildEnv: () => ({}) } as never, + abortController: controller, allowedTools: ["Read", "Write", "Edit", "NotebookEdit"], confirmPatterns: [], turnLock: lock, log: () => {} }); + try { + for (const name of ["Write", "Edit", "NotebookEdit"]) { + expect(turn.options.disallowedTools, "NATIVE_ROSTER_STABLE").not.toContain(name); + if (profile === "governed" && posture !== "voice") expect(turn.options.allowedTools, "GOVERNED_NATIVE_AUTO_ALLOWED").toContain(name); + else { + const input = { file_path: join(brain, "voyage.md"), notebook_path: join(brain, "loom.ipynb"), content: "Ithaca" }; + const results = []; + for (const group of turn.options.hooks!.PreToolUse!) { + if (!group.matcher || new RegExp(group.matcher).test(name)) for (const hook of group.hooks) results.push(await hook({ hook_event_name: "PreToolUse", tool_name: name, tool_input: input, tool_use_id: `toolu_${name}` } as never, undefined, { signal: controller.signal })); + } + expect(results.some(result => "hookSpecificOutput" in result && result.hookSpecificOutput?.hookEventName === "PreToolUse" && result.hookSpecificOutput.permissionDecision === "deny"), "NATIVE_CALL_TIME_DENIAL").toBe(true); + expect(await turn.options.canUseTool!(name, input, { toolUseID: `toolu_${name}`, signal: controller.signal } as never)).toMatchObject({ behavior: "deny" }); + } + } + expect(cards, "NO_NATIVE_PERMISSION_CARD").toBe(0); + } finally { await turn.finishWorker(); lock.close(); rmSync(brain, { recursive: true, force: true }); } + }); +} diff --git a/packages/ui-backend-claude/tests/native-hosted-runtime.test.ts b/packages/ui-backend-claude/tests/native-hosted-runtime.test.ts new file mode 100644 index 000000000..377c71118 --- /dev/null +++ b/packages/ui-backend-claude/tests/native-hosted-runtime.test.ts @@ -0,0 +1,107 @@ +/** Production SDK options and isolated worker, real CLI tools, scripted loopback inference. */ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { query, type HookCallback, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { createKeyedLock, type ServerMessage } from "@schlessera/brain-ui-sdk/server"; +import { createClaudeSdkTurn } from "../src/sdk-options"; +import { createFixtureWorkerStartupView } from "../../ui-sdk/tests/helpers/worker-startup-view-fixture"; +import { createTurnLockBinding } from "../src/turn-lock"; +import { StreamAdapter } from "../src/stream-adapter"; +import { MEASURED_RUNTIME } from "../src/measured-runtime"; + +function reply(call?: { name: string; input: Record; id: string }): Response { + const events: unknown[] = [{ type: "message_start", message: { id: "msg_ithaca", type: "message", role: "assistant", model: "claude-probe", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: call ? { type: "tool_use", id: call.id, name: call.name, input: {} } : { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: call ? { type: "input_json_delta", partial_json: JSON.stringify(call.input) } : { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 0 }, { type: "message_delta", delta: { stop_reason: call ? "tool_use" : "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, { type: "message_stop" }]; + return new Response(events.map(event => `event: ${(event as { type: string }).type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } }); +} + +let registeredTools: string | undefined; + +for (const toolName of ["Write", "Edit", "NotebookEdit"] as const) for (const mode of ["stale", "fresh", "strict", "voice"] as const) test(`hosted real Claude ${toolName}: ${mode}`, async () => { + const reread = mode === "fresh", recovered = mode === "fresh"; + const refused = mode === "strict" || mode === "voice"; + const root = mkdtempSync(join(tmpdir(), "odysseus-native-stale-")), brain = join(root, "brain"), home = join(root, "home"); + mkdirSync(brain); mkdirSync(home); + const notebook = (source: string) => JSON.stringify({ cells: [{ cell_type: "code", id: "loom", metadata: {}, source: [source], outputs: [], execution_count: null }], metadata: {}, nbformat: 4, nbformat_minor: 5 }); + const path = join(brain, toolName === "NotebookEdit" ? "loom.ipynb" : "loom.md"), before = toolName === "NotebookEdit" ? notebook("Penelope weaves.\n") : "Penelope weaves.\n", peer = toolName === "NotebookEdit" ? notebook("Penelope sleeps.\n") : "Penelope sleeps.\n"; + writeFileSync(path, before); utimesSync(path, new Date("2026-07-12T00:00:00Z"), new Date("2026-07-12T00:00:00Z")); + const stamp = statSync(path); + const calls = [{ name: "Read", input: { file_path: path }, id: "toolu_ithaca_read" }, + { name: toolName, input: toolName === "Write" ? { file_path: path, content: "Athena charts.\n" } : toolName === "NotebookEdit" ? { notebook_path: path, cell_id: "loom", new_source: "Athena charts.", edit_mode: "replace" } : { file_path: path, old_string: "Penelope", new_string: "Odysseus" }, id: "toolu_ithaca_write" }]; + if (reread) calls.splice(1, 0, { name: "Read", input: { file_path: path }, id: "toolu_ithaca_reread" }); + let roster: string | undefined, sent = 0, reads = 0, rereadContent: string | undefined, authorityDenial = false, nativeDenial = false, version: string | undefined; + const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 20_000); + 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 { tools?: { name: string }[] }; + roster ??= JSON.stringify(body.tools); + const call = calls[sent]; + if (!call || !body.tools?.some(tool => tool.name === call.name)) return reply(); + sent++; return reply(call); + } }); + const lockState = { ended: false, pendingReleases: new Map void>() }; + let lockHeldDuringWrite = false; + const frames: ServerMessage[] = [], lock = createTurnLockBinding({ turn: lockState, writeLock: createKeyedLock(), lockWaitMs: 1000, log: () => {} }); + let cards = 0; + const startupView = createFixtureWorkerStartupView(brain); + const turn = createClaudeSdkTurn({ backend: { brainPath: brain }, req: { + prompt: "Return to Ithaca.", signal: controller.signal, + posture: mode === "voice" ? "voice" : undefined, + workerStartupView: startupView, + workerSandbox: { brain, profile: mode === "strict" ? "strict" : "governed", restricted: false, areas: [], scratchAliasesRemoved: [] }, + bridge: { emit: () => {}, applyBrain: async () => ({}), requestPermission: async () => { cards++; return { behavior: "allow" }; } }, + } as never, + profile: { billing: "api", requiredEnvKeys: [], buildEnv: () => ({ HOME: home, CLAUDE_CONFIG_DIR: join(home, ".claude"), PATH: process.env.PATH ?? "/usr/bin:/bin", ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}`, ANTHROPIC_API_KEY: "odysseus-offline-only-key", ANTHROPIC_AUTH_TOKEN: undefined, CLAUDE_CODE_OAUTH_TOKEN: undefined, CLAUDE_CODE_USE_BEDROCK: "", CLAUDE_CODE_USE_VERTEX: "", CLAUDE_CODE_USE_FOUNDRY: "", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" }) } as never, + abortController: controller, allowedTools: ["Read", "Write", "Edit", "NotebookEdit"], confirmPatterns: [], turnLock: lock, log: () => {} }); + const wiring = turn.options; + const post = wiring.hooks!.PostToolUse![0]!.hooks[0]!; + const recordThenPeer: HookCallback = async (...args) => { + const result = await post(...args); + if (args[0].hook_event_name === "PostToolUse" && args[0].tool_name === toolName) lockHeldDuringWrite = lockState.pendingReleases.has(args[0].tool_use_id); + if (args[0].hook_event_name === "PostToolUse" && args[0].tool_name === "Read") { + reads++; + if (reads === 2) rereadContent = (args[0].tool_response as { file?: { content?: string } }).file?.content; + // A real other actor changes bytes, preserving mtime so a timestamp-only + // upstream check is not mistaken for this first-party SHA-256 guard. + if (reads === 1 && !refused) { writeFileSync(path, peer); if (!recovered) utimesSync(path, stamp.atime, stamp.mtime); } + } + return result; + }; + const hooks = { ...wiring.hooks, PostToolUse: [{ ...wiring.hooks!.PostToolUse![0]!, hooks: [recordThenPeer] }], + PreToolUse: wiring.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" && result.hookSpecificOutput.permissionDecisionReason?.includes("changed since you read it")) nativeDenial = true; + if ("hookSpecificOutput" in result && result.hookSpecificOutput?.hookEventName === "PreToolUse" && result.hookSpecificOutput.permissionDecision === "deny" && result.hookSpecificOutput.permissionDecisionReason?.includes("Native file writes are unavailable")) authorityDenial = true; + return result; + }) as HookCallback) })) }; + const adapter = new StreamAdapter(); + try { + for await (const message of query({ prompt: "Read the loom, then make the planned file change.", options: { ...turn.options, maxTurns: 4, model: "claude-sonnet-4-6", hooks, + // A hostile settings grant must not override the hosted call-time denial. + settings: refused ? { permissions: { allow: ["Write", "Edit", "NotebookEdit"] }, hooks: { PreToolUse: [{ matcher: "Write|Edit|NotebookEdit", hooks: [{ type: "command", command: "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}}'" }] }] } } : undefined, + } }) as AsyncIterable) { + if (message.type === "system" && message.subtype === "init") version = message.claude_code_version; + frames.push(...adapter.adapt(message)); + if (message.type === "user" && Array.isArray(message.message.content)) for (const block of message.message.content) if (block.type === "tool_result") lock.releaseForTool(block.tool_use_id); + } + expect(roster).toBeDefined(); + registeredTools ??= roster; + expect(roster, "REQUEST_VISIBLE_TOOL_DEFINITIONS_STABLE").toBe(registeredTools); + expect(authorityDenial, "FIRST_PARTY_NATIVE_AUTHORITY_DENIAL").toBe(refused); + expect(lockHeldDuringWrite, "REAL_NATIVE_WRITE_LOCK_HELD").toBe(recovered); + expect(version, "ACTUAL_INSTALLED_CLAUDE_FILE_TOOL_IDENTITY").toBe(MEASURED_RUNTIME.claudeCode); + expect(sent).toBe(reread ? 3 : 2); expect(reads).toBe(reread ? 2 : 1); + const expected = recovered ? (toolName === "Write" ? "Athena charts.\n" : "Odysseus sleeps.\n") : refused ? before : peer; + expect(cards, "REAL_HOSTED_NO_PERMISSION_CARDS").toBe(0); + if (toolName === "NotebookEdit" && recovered) expect(JSON.parse(readFileSync(path, "utf8")).cells[0].source).toBe("Athena charts."); + else expect(readFileSync(path, "utf8"), "CLAUDE_NATIVE_STALE_WRITE_EFFECT").toBe(expected); + expect(nativeDenial, "FIRST_PARTY_NATIVE_SHA_DENIAL").toBe(!recovered && !refused); + if (recovered && toolName !== "NotebookEdit") expect(rereadContent).toBe(peer); + else if (reread) expect(rereadContent).toBeUndefined(); + expect(frames.find(frame => frame.type === "tool_result" && frame.toolUseId === "toolu_ithaca_write")).toMatchObject({ isError: !recovered }); + } finally { clearTimeout(timer); controller.abort(); server.stop(true); await turn.finishWorker(); startupView.cleanup(); lock.close(); rmSync(root, { recursive: true, force: true }); } +}, 30_000); diff --git a/scripts/check-citations.ts b/scripts/check-citations.ts index 0d7e7f2a2..a59014003 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:188-211`) // // 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 From 7b3b4e5a75cf695024170f6f04bbeac14734c2db Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:14:21 +0200 Subject: [PATCH 2/4] docs(backends): refresh worker settings source citation --- packages/ui-backend-claude/src/permission-hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-backend-claude/src/permission-hooks.ts b/packages/ui-backend-claude/src/permission-hooks.ts index c46cb26c7..61e685a2d 100644 --- a/packages/ui-backend-claude/src/permission-hooks.ts +++ b/packages/ui-backend-claude/src/permission-hooks.ts @@ -369,7 +369,7 @@ export function createPermissionWiring(options: { // one; this is just its runtime binding. // // Allowlisting is not the only reason it belongs here. This backend loads - // the brain repo's project settings (`settingSources`, sdk-options.ts:207), + // the brain repo's project settings (`settingSources`, sdk-options.ts:218), // and a hook // declared in those can answer a call before canUseTool is reached at all // — #124 has the measurements. This hook fires either way. None of that From fd76066ec69ec99f6ed25fe8b91206ae8969ea0e Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:36:07 +0200 Subject: [PATCH 3/4] test(ui-server): assert strict native authority with stable registration --- .../ui-server/tests/brain-application-adapters.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui-server/tests/brain-application-adapters.test.ts b/packages/ui-server/tests/brain-application-adapters.test.ts index 3dd7b76bf..41b1b68bf 100644 --- a/packages/ui-server/tests/brain-application-adapters.test.ts +++ b/packages/ui-server/tests/brain-application-adapters.test.ts @@ -181,7 +181,13 @@ 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"); + for (const name of ["Write", "Edit", "NotebookEdit"]) { + expect(a.options!.disallowedTools).not.toContain(name); + expect(a.options!.allowedTools).toContain(name); + expect(await a.options!.canUseTool!(name, { file_path: "notes/raft.md" }, { + toolUseID: `strict-${name}`, signal: new AbortController().signal, + } as never)).toMatchObject({ behavior: "deny" }); + } }); test("hosted voice retains capture/update and refuses archive, raw writes and grants", async () => { From 3f77be8f7fdad6cda19b97a0ebb4451bcb038d06 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 16 Oct 2026 12:46:28 +0200 Subject: [PATCH 4/4] test(backends): assert native authority in policy runtime proof --- tests/policy-write-boundary.test.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/policy-write-boundary.test.ts b/tests/policy-write-boundary.test.ts index db90021e0..57d8e1e0a 100644 --- a/tests/policy-write-boundary.test.ts +++ b/tests/policy-write-boundary.test.ts @@ -138,9 +138,14 @@ 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"]); - for (const name of ["Write", "Edit", "NotebookEdit"]) for (const c of calls(r, name)) expect(c.result.isError).toBe(true); + // Strict turns retain native registration but refuse execution; the + // subagent's shell is confined too. + expect(r.receipt.unavailable).toEqual([]); + for (const name of ["Write", "Edit", "NotebookEdit"]) for (const c of calls(r, name)) { + expect(c.result.isError).toBe(true); + // The runtime may reject NotebookEdit's unread input before hooks. + if (name !== "NotebookEdit") expect(c.result.output).toContain("Native file writes are unavailable"); + } const sub = shells.filter(c => c.subagent); expect(sub.length, "the subagent issued its own shell write").toBe(1); expect(sub[0]!.result.output).toContain("Read-only file system"); @@ -213,7 +218,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, "the mask editor is outside voice registration").toEqual(["mcp__brain-ui__request_image_mask"]); + const native = calls(r, "Write")[0]!; + expect(native.result.isError).toBe(true); + expect(native.result.output).toContain("Native file writes are unavailable"); 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 +297,13 @@ 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 native registration, refuses its 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"); + const native = calls(r, "Write")[0]!; + expect(native.result.isError).toBe(true); + expect(native.result.output).toContain("Native file writes are unavailable"); 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);