verify plan mode is reachable on a bypassPermissions session
This commit is contained in:
+50
-10
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user