Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-native-voyage.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<file>.ts:' -- docs packages`. `tests/decision-citations.test.ts`
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/decisions/claude-2.1.287-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 7 additions & 7 deletions docs/decisions/claude-code-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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. |
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/design-kit/backend-measurements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions docs/decisions/governed-writable-workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
Expand Down
35 changes: 25 additions & 10 deletions docs/decisions/voice-permission.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.**
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
10 changes: 8 additions & 2 deletions docs/integration-contract/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
| --- | --- |
Expand Down
Loading
Loading