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 76cf43169..c99aa0a9e 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:183-212`), + (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:189-218`), 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 88b33244d..5cd4334f4 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -170,7 +170,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:183-212`) +(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:189-218`) ``` - **The anchor** is the code span immediately before the citation, joined to it diff --git a/docs/decisions/claude-2.1.287-compatibility.md b/docs/decisions/claude-2.1.287-compatibility.md index f3b4f9d0c..6fc892371 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:88-399`). The explicit ask lives in -(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:183-212`) +(`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:189-218`) and is registered under enforcement in -(`...(enforced ?`, `packages/ui-backend-claude/src/permission-hooks.ts:540-543`). +(`...(enforced ?`, `packages/ui-backend-claude/src/permission-hooks.ts:552-555`). `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 f7e269d22..18abcd10c 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:285`). Because of the `||` + `packages/ui-backend-claude/src/sdk-options.ts:295`). 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:151-154`). A version gate would separate "probed" from "not + `permission-hooks.ts:157-160`). 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:178-180`; `(e) a hook's`, + `permission-hooks.ts:184-186`; `(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: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. | +| `DO NOT WEAKEN THIS INTO A FALLTHROUGH`, `packages/ui-backend-claude/src/permission-hooks.ts:156-187` | 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:151-154` already says three is what has been measured, not a +`permission-hooks.ts:157-160` 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:68-74`, `envSnapshot`, `packages/ui-backend-claude/src/config/env.ts:182-190`), handed to the SDK - whole (`sdkOptions.env = childEnv`, `sdk-options.ts:356`). A declared bearer-token profile clears both + whole (`sdkOptions.env = childEnv`, `sdk-options.ts:366`). 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:232`) — and from a stored Console login, reported + settings (`settingSources: ["project"]`, `sdk-options.ts:240`) — 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 8cd1f684c..71d6435e1 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:232`, makes the CLI walk up + `packages/ui-backend-claude/src/sdk-options.ts:240`, 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 b043d295e..715978de1 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, @@ -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:90`). +`packages/ui-backend-claude/src/permission-hooks.ts:96`). Claude keeps these observations in its backend-owned session map (`const fileVersionsBySession`, diff --git a/docs/decisions/voice-permission.md b/docs/decisions/voice-permission.md index dee078ddf..c264274b7 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:130-155`) +`packages/ui-backend-claude/src/sdk-options.ts:134-159`) 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:130-155`). `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:134-159`). `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:281-291`) and is not +`permission-hooks.ts:289-299`) 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:130`, and what it -becomes, `allowedTools: allowed`, `:246`). The voice posture is one +`allowed` array, from `const allowed`, `sdk-options.ts:134`, and what it +becomes, `allowedTools: allowed`, `:254`). The voice posture is one named entry in that mechanism. **#51's U15 originally chose availability control for the same bypass reason.** @@ -598,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:508-527`). + `const agentHook`, `permission-hooks.ts:520-539`). 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 @@ -615,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:183-212`) — that is what beats the +(`enforcementHook`, `permission-hooks.ts:189-218`) — 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 @@ -635,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:150-181`, with the measurements in #154) — a safe-command +`permission-hooks.ts:156-187`, 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 @@ -688,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:224-226`, and the call in `mutatingHook` that reads it, -`decideToolPermission`, `:290-297`). So a +`permission-hooks.ts:230-232`, and the call in `mutatingHook` that reads it, +`decideToolPermission`, `:298-305`). 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 @@ -832,3 +832,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 7b7b2f85f..5d6fcbed8 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 7438c54e6..0e01fb02e 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:183-212`). 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:189-218`). 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`)), diff --git a/docs/plans/async-collaboration.md b/docs/plans/async-collaboration.md index bf459fe46..83db10f2d 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:183-212`). +`canUseTool`; the existing mandatory posture catches measured bypasses with hooks (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:189-218`). 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,7 +137,7 @@ 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:183-212`) closes measured bypasses. +- **Tool enforcement:** (`const enforcementHook: HookCallback`, `packages/ui-backend-claude/src/permission-hooks.ts:189-218`) 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 diff --git a/packages/ui-backend-claude/src/permission-hooks.ts b/packages/ui-backend-claude/src/permission-hooks.ts index 281d930ec..4bdf8e00f 100644 --- a/packages/ui-backend-claude/src/permission-hooks.ts +++ b/packages/ui-backend-claude/src/permission-hooks.ts @@ -41,6 +41,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; @@ -57,6 +59,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 => { @@ -261,6 +267,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 unavailable = toolAvailabilityRefusal(req, toolName); if (unavailable) return { behavior: "deny", message: unavailable }; const denied = denyCommand(toolName, input, opts.toolUseID); @@ -351,9 +359,13 @@ export function createPermissionWiring(options: { if (hookInput.hook_event_name !== "PreToolUse") { 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 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 } }; 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 99b1425e1..5a9b0327d 100644 --- a/packages/ui-backend-claude/src/sdk-options.ts +++ b/packages/ui-backend-claude/src/sdk-options.ts @@ -127,7 +127,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. @@ -246,10 +254,12 @@ 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"] : [])], + disallowedTools: ["AskUserQuestion", ...((hosted || req.autonomous) ? [ + "mcp__brain__brain_add", "mcp__brain__brain_update", "mcp__brain__brain_archive", + ] : []), ...(hosted && req.autonomous && (sandbox.profile !== "governed" || req.posture === "voice") ? ["Write", "Edit", "NotebookEdit"] : [])], ...createPermissionWiring({ req: { ...req, workerSandbox: sandbox }, + 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/packages/ui-server/tests/brain-application-adapters.test.ts b/packages/ui-server/tests/brain-application-adapters.test.ts index 1fa3ceff1..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).not.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 () => { diff --git a/scripts/check-citations.ts b/scripts/check-citations.ts index a5cffe297..b26527e92 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:183-212`) +// (`enforcementHook`, `packages/ui-backend-claude/src/permission-hooks.ts:189-218`) // // 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 diff --git a/tests/policy-write-boundary.test.ts b/tests/policy-write-boundary.test.ts index c6a5cb9cd..9d5a72be5 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 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); + // 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"); @@ -217,6 +222,7 @@ for (const adapter of ["claude", "pi"] as const) describe(`${adapter} policy-wri 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(calls(r, "Write")[0]!.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")); @@ -292,11 +298,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 narrow authority 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).not.toContain("Write"); - for (const c of calls(r, "Write")) expect(c.result.isError, "governed voice refuses native writes").toBe(true); + 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);