verify plan mode is reachable on a bypassPermissions session

This commit is contained in:
2026-08-17 19:10:24 +00:00
parent a4907fdfdb
commit 9be484a105
+50 -10
View File
@@ -3,7 +3,8 @@
Agreed with the owner on 2026-08-17, immediately before a context compaction. Written so the build can start
from here with nothing re-derived.
**Status: not started.** Nothing in this document has been implemented.
**Status: step 1 verified against a real `claude` (SDK 0.3.233), nothing built yet.** The mechanism works and
the open question below is answered — see "Verified" before implementing.
---
@@ -72,12 +73,47 @@ type CanUseTool = (toolName: string, input: Record<string, unknown>, options: {
So the shape is: intercept `ExitPlanMode`, surface the plan to the user, and resolve the promise with allow or
deny. That makes the plan a first-class thing in the transcript rather than a message that scrolls past.
**Open question to settle first:** does `canUseTool` fire at all under `permissionMode: 'plan'` when the
session was created with `bypassPermissions`, and can the mode be changed by control request _after_
`createSession` has already passed `canUseTool` (or not passed it)? `canUseTool` is a `query()` OPTION, read
once at session creation — so it may have to be supplied unconditionally from the start, refusing nothing
until plan mode is entered. **Verify this before designing the UI**, because it decides whether plan mode can
be toggled on a live session or only chosen when a conversation starts.
### Verified — 2026-08-17, against a real `claude`, SDK 0.3.233
Three probe runs against a scratch directory. Every answer below was observed, not read.
1. **`setPermissionMode('plan')` works on a session created with `bypassPermissions` + `allowDangerouslySkipPermissions`.**
It resolves cleanly, including before the first turn has been sent.
2. **`canUseTool` does fire in plan mode**, on that same session — for `ExitPlanMode` and for
`AskUserQuestion`. It does NOT fire for read-only tools (`Read`, `Bash`, `Task`). So the interception
point the design needs exists, and it is quiet.
3. **Plan mode overrides `bypassPermissions` for execution.** The agent emitted a `Write` tool_use while
planning; it reached neither `canUseTool` nor the disk (`notes.txt` was byte-identical afterwards). The
harness refuses execution tools upstream of the permission layer, so plan mode is a real constraint here
and not merely advisory.
4. **Supplying `canUseTool` unconditionally at `createSession` is free.** Under `bypassPermissions` the SDK
shadows it entirely and says so, once, at query creation:
```
Warning: canUseTool will not be invoked: permissionMode 'bypassPermissions' auto-approves every tool
call (except explicit deny rules) before the callback is consulted. code: CLAUDE_SDK_CAN_USE_TOOL_SHADOWED
```
A `bypassPermissions` session asked to edit a file made the edit with **zero** `canUseTool` calls. This
settles the design question: because `canUseTool` is a `query()` option read once, it must be passed from
the start — and passing it always costs nothing but a log line. **Plan mode can therefore be toggled on a
live session**, which is what the composer toggle needs.
5. **Approving `ExitPlanMode` exits plan mode by itself — into `default`, NOT back into `bypassPermissions`.**
This is the one thing the plan above got wrong, and it is the dangerous one. Returning `{behavior:'allow'}`
for `ExitPlanMode` caused the harness to leave plan mode and the agent to carry straight on **within the
same turn** — and its follow-up `Edit` DID hit `canUseTool`. So approval silently converts an unsandboxed
session into a gated one. Restoring `bypassPermissions` is not tidying up after the feature; it is required
for the session to behave as it did before plan mode was ever entered.
6. **Denial is the revision path.** `{behavior:'deny', message}` ends the tool call, not the turn, and the CLI
carries a matching string: _"Please revise your plan based on the feedback and call ExitPlanMode again."_
So the reject button should take a reason and send it as `message`.
The probe that produced this was `scripts/probe-plan-mode.ts`, deleted once these answers were written down.
---
@@ -92,6 +128,12 @@ to `bypassPermissions` or every subsequent tool call starts prompting and the ch
socket is closed must resolve (deny, or on a timeout) or the session wedges with `isGenerating` true — the
same failure the stall timer exists to catch (`claude-manager.ts`, `stallTimer`).
**Restore `bypassPermissions` the moment `ExitPlanMode` is allowed, not at turn end.** Finding 5 above: the
agent resumes work inside the same turn, so a restore deferred to the `result` message leaves a window in
which every tool call is gated by a callback written to gate exactly one tool. `canUseTool` must therefore
allow everything that is not `ExitPlanMode` — never deny by default — because for a few hundred milliseconds
it is the only thing standing between the agent and its own tools.
**Mode is per-session state and belongs with the model.** `session.model` on `PersistentSession` was added for
exactly this class of thing; a `session.permissionMode` beside it keeps `applyModelChange`'s shape.
@@ -99,9 +141,7 @@ exactly this class of thing; a `session.permissionMode` beside it keeps `applyMo
## Sequence
1. **Verify the `canUseTool` question above** with a scratch script against a real `claude` — do not design
the UI first. (Note: a scratch `query()` failed to reach `supportedModels()` from a nested Claude Code
session earlier today; the sidecar itself is the reliable place to test.)
1. ~~Verify the `canUseTool` question~~ — **done**, see "Verified" above.
2. `session.permissionMode` on `PersistentSession`, plus `applyPermissionModeChange` mirroring
`applyModelChange` — control request, effective-not-requested, durable event on success and on refusal.
3. `plan:entered` / `plan:proposed` / `plan:exited` durable events through the five files `model:switched`