refactor(mode): drop the per-mode tool allowlist — enforce where an enforcer exists

A ModeDefinition is now exactly { section, access? }; unknown keys (a
tools list included) fail loud at load. What plan mode still does: the
guidance section, the exit_plan_mode visibility rule (plan only, both
soft surfaces), the access cap's bash/resolve-mode clamp, and the two
cap-derived pre-execute guards (the bash trio is withheld when no
confining executor can honor the cap; sandbox escalation is denied
while it holds). The general deny-by-default gate and the assemble
allowlist filter are gone: which tools a mode admits is an effects
question, and a hand-maintained name list mislabels it — it must track
every composed tool and rots silently as tools arrive. The dimension
returns as a consumer of effects self-declaration on tool definitions
(MCP ToolAnnotations as the template) — rationale and restart trigger
archived in the RFC's Alternatives/Deferred; the interim guidance-only
non-shell restraint is priced in Consequences.

Exiting plan is now a pure removal (the exit tool + section), which the
delta encoding CAN express: the re-recorded plan-mode fixture pins one
plan-shaped initial header snapshot plus one header-delta instead of
two snapshots.
This commit is contained in:
kingwl
2026-07-12 23:20:36 +08:00
parent 99650a201b
commit 1fe2f99580
20 changed files with 1995 additions and 1529 deletions

View File

@@ -457,15 +457,14 @@ export interface ModeConfig {
}
/**
* One mode's deployment-configured policy: the guidance section the model sees,
* the allowlist of tool names that stay visible and executable, and an
* optional cap on the sandbox access shell commands run under.
* One mode's deployment-configured policy: the guidance section the model sees
* and an optional cap on the sandbox access shell commands run under. There
* is deliberately no tool allow/deny list — which tools a mode admits is an
* effects question, parked until tool definitions declare their effects.
*/
export interface ModeDefinition {
/** Guidance text rendered as the `mode:policy` prompt section while the mode is in force. */
section: string
/** Allowlist of tool NAMES; names may reference not-yet-registered tools (registration is dynamic). */
tools: string[]
/**
* The widest sandbox access shell commands may run under while this mode is
* in force — a per-call CAP on the bash seam's resolved mode (a
@@ -479,7 +478,7 @@ export interface ModeDefinition {
}
```
Source: [`packages/mode/mode/src/index.ts:115`](../packages/mode/mode/src/index.ts)
Source: [`packages/mode/mode/src/index.ts:118`](../packages/mode/mode/src/index.ts)
## `@deepseek-ai/dsh-repeat-tool-guard`

View File

@@ -102,7 +102,7 @@ Every product feature maps to a listener on a documented extension seam — the
| ToolSearch / progressive disclosure | filter tools at `system-prompt/assemble` (the assembly carries the schemas; the loop logs the result as the request header, so disclosure stays reconstructable) |
| Subprocess sandbox (landlock / sandbox-exec) | `tools/pre-execute` (deny), or a sandboxing `BashExecutor` on the `dsh-bash` seam |
| Permission system / AskUserQuestion | `tools/pre-execute` (deny/ask); register an ask tool |
| Plan mode | Shipped: [`@deepseek-ai/dsh-mode`](../../packages/mode/mode/README.md) — the logged `mode/set` state, the assemble filter + `mode:policy` section, the deny-by-default `tools/pre-execute` gate, and the user-reviewed `exit_plan_mode` exit |
| Plan mode | Shipped: [`@deepseek-ai/dsh-mode`](../../packages/mode/mode/README.md) — the logged `mode/set` state, the `mode:policy` guidance section, the sandbox `access` cap (a `bash/resolve-mode` clamp + cap-derived bash guards), and the user-reviewed `exit_plan_mode` exit |
| Sub-agent delegation | the `ctx.subagents` provider registry (`dsh-subagent-spawn`/`-fork`/`-acp`) + `dsh-tool-subagent` exposing one configured provider to the model |
| MCP | one plugin per server: discover tools → `ctx.tools.register()` |
| Skills | section + tool registration; `inject()` skill content on invocation |

View File

@@ -174,7 +174,7 @@ set(agent: Agent, mode: string): void
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/mode/mode/src/index.ts:255`](../../packages/mode/mode/src/index.ts)
Source: [`packages/mode/mode/src/index.ts:252`](../../packages/mode/mode/src/index.ts)
## `ctx.sandbox` — `SandboxProvider` (abstract seam)

View File

@@ -163,7 +163,7 @@ The session mode in force from this point on: log-only, non-surface, whole-value
'mode/set': { mode: string }
```
Source: [`packages/mode/mode/src/index.ts:49`](../packages/mode/mode/src/index.ts)
Source: [`packages/mode/mode/src/index.ts:53`](../packages/mode/mode/src/index.ts)
### `prompt/*`

View File

@@ -10,11 +10,11 @@ Every shipped plan mode decomposes into the same five parts — a low-authority
## Decision
The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent policy state: mode definitions — which tools stay visible, what guidance section renders — are deployment config, and the mode IN FORCE for an agent is session state, folded from its log. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/` (a new top-level group, the `packages/approval/` shape), owns the event vocabulary, a thin `ctx.modes` service, and every policy listener; the loop does not change. `plan` is the only shipped definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now.
The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent policy state: mode definitions — the guidance section the model sees, the sandbox access cap — are deployment config, and the mode IN FORCE for an agent is session state, folded from its log. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/` (a new top-level group, the `packages/approval/` shape), owns the event vocabulary, a thin `ctx.modes` service, and every policy listener; the loop does not change. `plan` is the only shipped definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now.
The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of policy — no section, no filtering, no gate — so an agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads `dsh-mode`, which keeps every existing snapshot golden stable and makes the plugin safe to compose unconditionally.
Enforcement is two layers that cover each other. The **soft** layer is a `system-prompt/assemble` listener that filters the tool schemas down to the mode's allowlist and appends the mode's guidance section — every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible, the full fallback snapshot otherwise), keeping the [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) invariant green by construction. The **hard** layer is a `tools/pre-execute` listener that denies, deny-by-default against the same allowlist, any call the mode does not permit — so a hallucinated call to a still-registered tool, or a schema re-widened by a foreign assemble listener, still cannot run.
Enforcement is deliberately scoped to where an enforcer exists. The **soft** surface is a `system-prompt/assemble` listener that renders the mode's guidance section and shows the `exit_plan_mode` tool IFF the folded mode is `plan` — every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible, the full fallback snapshot otherwise), keeping the [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) invariant green by construction. The **enforced** surface is the mode's `access` cap on the bash seam: a `bash/resolve-mode` clamp that keeps every shell command inside the declared sandbox access, plus two cap-derived `tools/pre-execute` guards (the bash tools are withheld when no confining executor can honor the cap; sandbox escalation is denied while the cap holds). There is NO general tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); until then a mode's non-shell restraint is the section's guidance.
The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan a durable log artifact, and the tool conducts the review itself through the user-interaction seam — a question with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed.
@@ -22,13 +22,13 @@ The model leaves plan mode through the **`exit_plan_mode`** tool: its single arg
### A plan-mode session end to end
The user switches the session to plan mode — the ACP mode picker or the stdio `/mode plan` — and from the next turn every request ships the filtered read-only toolset plus the plan-mode guidance section.
The user switches the session to plan mode — the ACP mode picker or the stdio `/mode plan` — and from the next turn every request ships the plan guidance section and the `exit_plan_mode` tool.
The model explores and designs with what remains — the bash tools included when the composition confines them: plan mode's `access: read-only` cap clamps every command to a read-only sandbox, so exploration runs for real; if the model attempts a write anyway, the gate denies with a reason naming the mode and pointing at `exit_plan_mode`, and the transcript keeps planning.
The model explores and designs — the bash tools clamped to a read-only sandbox by plan mode's `access` cap when the composition confines them, so exploration commands run for real while a write is denied by the sandbox itself; outside the shell, the section's guidance is what defers changes into the plan.
When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the UI renders the plan as the call card and the review question arrives through the user-interaction channel — approve, or keep planning, with free-text feedback welcome — so what the human reviews is exactly the logged artifact.
On approve, the tool flips the logged mode back to the default: the next step runs with the full toolset and the widening header event in the log, and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents.
On approve, the tool flips the logged mode back to the default: the next step runs unclamped with the narrowing-back header event in the log, and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents.
### Deployment configuration
@@ -43,11 +43,10 @@ Mode definitions are validated plugin Config — per repo convention, changeable
section: |
You are in plan mode: explore and design, then present the
plan for approval through exit_plan_mode.
tools: [read, todo_write, web_search, web_fetch, ask_user_question, structured_output, bash, bash_output, bash_kill, exit_plan_mode]
access: read-only
```
`plan`'s shipped default allowlist is the read-only surface: `read`, `todo_write`, `web_search`/`web_fetch`, `ask_user_question`, `structured_output`, `exit_plan_mode` (the last three are the pure ask/report class), plus the bash trio under the `access: read-only` cap — CONDITIONAL on a confining executor ([the access cap](#the-access-cap-clamping-the-bash-resolution)); `subagent` stays excluded until mode inheritance lands. `default` is reserved (the absence of policy) and rejected as a key; an `access` outside the `SANDBOX_MODES` ladder fails validation at load; an unknown mode name fails loudly at `set()` time.
A definition is exactly `{ section, access? }`. The shipped `plan` pairs its guidance section with `access: read-only` ([the access cap](#the-access-cap-clamping-the-bash-resolution)); there is deliberately no per-mode tool list ([FAQ](#faq)). `default` is reserved (the absence of policy) and rejected as a key; an `access` outside the `SANDBOX_MODES` ladder — or any unknown definition key, a `tools` list included — fails validation at load; an unknown mode name fails loudly at `set()` time.
### In the terminal
@@ -76,17 +75,16 @@ The payload carries no reason/provenance field: a tool-driven flip sits next to
### Config and the resolve step
```text
interface ModeDefinition { section: string; tools: string[]; access?: SandboxMode }
// prompt text; allowlist of tool NAMES; optional cap on the
// sandbox access shell commands run under while the mode holds
interface ModeDefinition { section: string; access?: SandboxMode }
// prompt text; optional cap on the sandbox
// access shell commands run under while the mode holds
interface ModeConfig { modes?: Record<string, ModeDefinition> } // plan's built-in definition merged unless overridden
resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud:
// 'default' as a key rejected; access validated against
// the SANDBOX_MODES ladder; allowlists may name
// not-yet-registered tools (registration is dynamic)
// the SANDBOX_MODES ladder; any unknown key rejected
```
The allowlist is deliberately the degenerate form of a future per-tool decision map (`allow | deny | ask`): execution-phase ask policies (an every-write-asks "guarded" mode) stay deferred until the approval seam grows durable grants (`allow_always` — its own open question), and the config shape must not need a migration when they arrive.
The two-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions ([Deferred](#deferred)), read here rather than re-declared per mode — the config shape must not need a migration when it arrives.
### The fold, the service, and the flush
@@ -94,26 +92,26 @@ The allowlist is deliberately the degenerate form of a future per-tool decision
A contained `session/event` listener ([defensive patterns](../../../defensive-patterns.md): a policy plugin must not kill the feed) flushes the pending intent as a `mode/set` append on the next `turn/start` or `step/end` — both sit outside the step's tool-execution window, so the executions of a step always run under the mode its assembly folded — and, when the flushed mode differs from the fold at the last `request/header`, appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq). Seeding rides `agent/created`: `AgentOptions.mode` becomes a pending intent, so explicit options beat the logged baseline on create AND resume — the same precedence the call-config seed follows.
### The soft layer: a computed section and a post-`next()` filter
### The soft layer: a computed section and a narrow post-`next()` filter
A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in a non-default mode, filters `assembly.tools` down to the mode's allowlist and appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces on the next step as a `request/header-delta` — or as the full `request/header` fallback snapshot when the change is inexpressible in the delta encoding (adding `exit_plan_mode` resorts the canonical tool list, and a pure reordering has no delta form) — so every mode transition is an attributable log fact. The section is static per mode and the plan itself stays in the conversation (messages and tool args, already in context), so a mode does not add per-step prompt churn — re-injecting plan state into every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary and would only burn prefix cache.
A `system-prompt/assemble` waterfall listener reads the calling agent's mode (the `AssembleContext` carries `agent`) and, in a non-default mode, appends the mode's guidance section. The loop already renders per step and logs the result: entering or leaving a mode surfaces on the next step as a `request/header-delta` — or as the full `request/header` fallback snapshot when the change is inexpressible in the delta encoding (adding `exit_plan_mode` is a front-of-list insertion with no delta form; removing it on exit is a pure removal and DOES delta) — so every mode transition is an attributable log fact. The section is static per mode and the plan itself stays in the conversation (messages and tool args, already in context), so a mode does not add per-step prompt churn — re-injecting plan state into every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary and would only burn prefix cache.
The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The tool filter wraps with `prepend: true`: it awaits `next()` and filters the RETURNED assembly's `tools`, so additions made anywhere inside its wrap — including every append-registered listener's post-`next()` mutation, regardless of load order — are covered. `run_code` survives the filter in every mode: under the registry's Code Mode it is the only wire tool (filtering it would strip the model of everything, the exit included), and it is a transport, not a capability — each bridged sub-call re-enters the hard gate individually. Code Mode's soft surface is the `tools:sdk` section rather than the wire schemas, and section text resolves in assemble's base, so the same wrapper re-renders that section under the mode's visibility rule — the prompt documents exactly the callable bindings, never one the gate would deny. The default mode re-renders it too, hiding only the exit binding: that keeps a default-mode Code Mode assembly byte-identical to a deployment that never loaded `dsh-mode` (whose registry never saw the tool), instead of advertising a binding that can only error. The filter enforces one rule in every mode: `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which is also what keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered. In a non-default mode it additionally intersects with the mode's allowlist.
The guidance section is an ordinary registered section, `{ name: 'mode:policy', order: 50, text: context => … }` — order 50 sits after the persona (0) and before tool guidance (100199); it resolves to the folded mode's configured text and to `''` (dropped at render) for the default mode or an agent-less assembly. The same listener carries the one tool filter left, deliberately narrow (`prepend: true`, so it wraps outside every append-registered listener's post-`next()` mutation): `exit_plan_mode` is visible IFF the agent's folded mode is `plan` — which keeps a default-mode assembly byte-identical to a no-`dsh-mode` deployment even though the tool is always registered — and the bash trio is hidden while an access cap cannot be honored ([the access cap](#the-access-cap-clamping-the-bash-resolution)). Foreign additions pass through untouched: which tools a mode admits is not this plugin's decision. Code Mode's soft surface is the `tools:sdk` section rather than the wire schemas, and section text resolves in assemble's base, so the same wrapper re-renders that section under the same visibility rule; the default mode re-renders it too, hiding only the exit binding, keeping a default-mode Code Mode assembly byte-identical to a deployment that never loaded `dsh-mode`.
### The hard layer: the gate
### The cap-derived guard at `tools/pre-execute`
The gate denies, with a mode-naming reason that steers the model back to planning, any call outside the mode's allowlist. This layer is not redundant with the filter: [`ToolRegistry.execute()`](../../../../packages/core/tools/README.md) dispatches any registered tool by name, so a model hallucinating a filtered-out (or MCP-registered) tool would still run it without the gate. Deny-by-default against the allowlist also means the two layers cover each other — a peer `assemble` listener that re-widens the schema set cannot make the widened tools executable.
The one execution-phase listener is scoped to the `access` cap; without a declared cap (or in the default mode, or for an agent-less call) every call passes through. It exists because [`ToolRegistry.execute()`](../../../../packages/core/tools/README.md) dispatches any registered tool by name — the assemble filter alone cannot stop a hallucinated call to a hidden bash tool, and neither surface may let a capped mode run an unconfinable shell.
```text
tools/pre-execute: no exec.agent → next() // agent-less calls have no session to fold
folded mode = default → next()
exec.name = run_code → next() // transport: every bridged sub-call re-enters
// this gate with the same agent
allowlisted → next() // plan's list includes exit_plan_mode
otherwise → deny // reason names the mode and points at exit_plan_mode
tools/pre-execute: no exec.agent → next() // agent-less calls have no session to fold
folded mode default / no access → next()
name not in {bash, bash_output, bash_kill} → next()
no confining executor → deny // the cap cannot be honored; never run unconfined
name = bash with sandbox_permissions → deny // no widening mid-mode; plan the step instead
otherwise → next() // the clamp (below) confines execution
```
The gate folds the LOGGED mode only, never the pending intent — enforcement judges by the same state the request's header shipped under. The gate never returns `{ kind: 'ask' }`: the exit review is a question with options and feedback, not a permission, so it lives inside the tool's own execution over the user-interaction seam and the registry's `ask` vocabulary stays free for genuine permission gating. A call to `exit_plan_mode` outside plan mode reaches the tool only from the default mode (any other mode's allowlist excludes it), and the tool's own folded-mode recheck rejects it there.
The guard folds the LOGGED mode only, never the pending intent — enforcement judges by the same state the request's header shipped under. It never returns `{ kind: 'ask' }`: the exit review is a question with options and feedback, not a permission, so it lives inside the tool's own execution over the user-interaction seam and the registry's `ask` vocabulary stays free for genuine permission gating. A call to `exit_plan_mode` outside plan mode is rejected by the tool's own folded-mode recheck.
### The access cap: clamping the bash resolution
@@ -121,11 +119,11 @@ A definition's `access` is the widest sandbox access shell commands run under wh
The composition is read-time over two independent log folds. The session's sandbox knob (`bash/sandbox-mode` events, the sandbox RFC) is never written by the mode: a knob flipped during plan stays logged but capped, exiting plan uncovers it unchanged, the two switch in any order without interference, and there is no restore step for a crash to strand. Attribution stays log-adjacent on both axes — a clamped call's width is explained by the standing `mode/set`, a knob change by its own `bash/sandbox-mode` event.
Two rules ride with a declared cap. The bash trio (`bash`/`bash_output`/`bash_kill`) is CONDITIONAL: both policy layers admit it only while a confining executor is mounted (`ctx.get('bash')?.sandboxMode` set) — an unconfinable shell cannot honor the cap, so under `dsh-bash-local` the trio is hidden by the filter and denied by the gate exactly like a non-allowlisted tool. And sandbox ESCALATION is denied at the gate: a `bash` call carrying `sandbox_permissions` in a capped mode gets a deny that points the model at putting the widened step in the plan — without this rule the clamped resolution would read as a legitimate escalation baseline and one approval prompt could pierce the cap mid-mode. A mode that allowlists `bash` WITHOUT `access` is the deployment's explicit uncapped choice; neither rule applies.
Two rules ride with a declared cap. The bash trio (`bash`/`bash_output`/`bash_kill`) is CONDITIONAL: both surfaces admit it only while a confining executor is mounted (`ctx.get('bash')?.sandboxMode` set) — an unconfinable shell cannot honor the cap, so under `dsh-bash-local` the trio is hidden by the filter and denied by the guard. And sandbox ESCALATION is denied at the guard: a `bash` call carrying `sandbox_permissions` in a capped mode gets a deny that points the model at putting the widened step in the plan — without this rule the clamped resolution would read as a legitimate escalation baseline and one approval prompt could pierce the cap mid-mode. A mode without `access` gets neither rule — its shell is the deployment's explicit uncapped choice.
### `exit_plan_mode`
`defineTool` with one required `plan: string` argument — the plan is thereby a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), re-checks the folded mode as defense in depth, then conducts the review: one single-select `ctx.userInteraction.ask()` question — approve, or keep planning — with the free-text channel open for feedback. Approve records the switch back to `default` as a SILENT boundary-applied pending intent (flushed at this step's end, still in-turn) and returns a short confirmation; the gate therefore stays plan-mode for every remaining call of the SAME assistant response — a same-batch `exit_plan_mode` + `write` pair cannot smuggle the write past a request assembled under the plan header and the next step's assembly restores the full toolset and logs the widening header event. Every other outcome — keep-planning (the user's feedback text carried verbatim), an aborted question, a missing provider — returns the corrective `isError` that tells the model to revise and re-present, and the mode stays `plan`.
`defineTool` with one required `plan: string` argument — the plan is thereby a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), re-checks the folded mode as defense in depth, then conducts the review: one single-select `ctx.userInteraction.ask()` question — approve, or keep planning — with the free-text channel open for feedback. Approve records the switch back to `default` as a SILENT boundary-applied pending intent (flushed at this step's end, still in-turn) and returns a short confirmation; the plan policy — the sandbox clamp, the exit tool's visibility — therefore keeps holding for every remaining call of the SAME assistant response (they were requested under the plan-shaped header), and the next step runs unclamped, logging the pure-removal header-delta. Every other outcome — keep-planning (the user's feedback text carried verbatim), an aborted question, a missing provider — returns the corrective `isError` that tells the model to revise and re-present, and the mode stays `plan`.
Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card — the review question arrives beside this already-streamed card, so what the human reviews is exactly the logged artifact. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq).
@@ -135,7 +133,7 @@ Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent
### The recorded scenario and the harness op
`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue (FIFO, consumed by the harness client's elicitation callback — the review question's answer). The `plan-mode` scenario: initialize → newSession → setMode(plan) → a prompt the model answers by running a real `cat` through the bash tool INSIDE plan (executed under the clamped read-only sandbox on replay — Seatbelt on macOS, bwrap on Linux CI) and presenting the plan via `exit_plan_mode` → a scripted approve → the same turn's next step writes for real under the restored toolset. Because the mode is set before turn 1, the FIRST `request/header` snapshot is already in plan shape (filtered tools + section, reason `initial`) the widening header event appears at the exit; the scenario pins both, plus the `mode/set` pair. A sibling `plan-mode-reject` scenario scripts the keep-planning answer with feedback text and pins the corrective result. Both need a with-key recording session; the deny/reject texts — and the sandbox-denial marker, whose recorded stderr would be the backend's dialect and replay only where it was recorded — are pinned at the unit tier.
`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue (FIFO, consumed by the harness client's elicitation callback — the review question's answer). The `plan-mode` scenario: initialize → newSession → setMode(plan) → a prompt the model answers by running a real `cat` through the bash tool INSIDE plan (executed under the clamped read-only sandbox on replay — Seatbelt on macOS, bwrap on Linux CI) and presenting the plan via `exit_plan_mode` → a scripted approve → the same turn's next step edits for real, unclamped. Because the mode is set before turn 1, the FIRST `request/header` snapshot is already in plan shape (the full toolset plus the exit tool, the mode section, reason `initial`); the approved exit logs the pure-removal `request/header-delta`, and the scenario pins both, plus the `mode/set` pair. A sibling `plan-mode-reject` scenario scripts the keep-planning answer with feedback text and pins the corrective result. Both need a with-key recording session; the guard's deny texts — and the sandbox-denial marker, whose recorded stderr would be the backend's dialect and replay only where it was recorded — are pinned at the unit tier.
### The mechanical tail
@@ -143,9 +141,9 @@ No new cordis event is declared IN `dsh-mode` (`mode/set` rides `session/event`;
## Deferred
Each behind its own decision: subagent mode inheritance via a forwarded `AgentOptions.mode` (the option field itself ships), per-tool `ask` policies inside mode definitions (an OpenCode-style "bash asks in plan mode"), preset modes beyond `plan` (read-only, accept-edits), effects self-declaration on tool definitions (so the conditional-tool rule generalizes past the hardcoded bash trio — the MCP `ToolAnnotations` vocabulary is the natural template), and the idle-record primitive if pending-intent loss proves real.
Each behind its own decision: subagent mode inheritance via a forwarded `AgentOptions.mode` (the option field itself ships), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this RFC first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger.
The recorded snapshot scenarios are landed: `plan-mode` (the pinned-header arc — plan-shaped initial header, scripted elicitation approve, the boundary-flushed flip and widened fallback header, a real edit) and `plan-mode-reject` (keep-planning feedback carried verbatim in the corrective `isError`), beside the keyless `modes-advertise` wire golden. The gate's deny path stays pinned at the unit tier — the recorded model never calls a filtered tool, which is the behavior the soft layer exists to produce.
The recorded snapshot scenarios are landed: `plan-mode` (the pinned-header arc — plan-shaped initial header, a real `cat` under the clamp, scripted elicitation approve, the pure-removal header-delta, a real edit) and `plan-mode-reject` (keep-planning feedback carried verbatim in the corrective `isError`), beside the keyless `modes-advertise` wire golden. The cap guard's deny paths stay pinned at the unit tier.
## FAQ
@@ -157,13 +155,15 @@ Behavioral clarifications of the chosen design; rejected designs live in [Altern
**What happens on resume when the config no longer defines the folded mode?** One read-path rule closes the gap: a folded mode name the current config no longer defines behaves as the default mode plus one boundary notice naming the dropped definition — never a silent substitute restriction, never a bricked session. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds.
**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it never thrashes against the gate.
**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling.
**Why is there no per-mode tool allowlist?** Because "which tools are safe in a read-only mode" is a property of each TOOL (its effects), not of the mode — a per-mode name list re-declares that fact in the wrong home, must enumerate every tool the deployment composes (MCP servers included), and rots silently as tools arrive. Plan mode therefore enforces exactly where an enforcer exists — the sandbox cap on the shell — and restrains everything else by its section until tool definitions declare their effects ([Deferred](#deferred), where the removed interim allowlist is archived with its restart trigger); the interim exposure is an accepted cost ([Consequences](#consequences)).
**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode unless its creator seeds `AgentOptions.mode`; automatic forwarding by subagent providers is deferred ([Deferred](#deferred)).
**How does plan mode's read-only relate to the sandbox knob's read-only?** They are two independent folds over the same log with different owners: the knob (`bash/sandbox-mode`, [the sandbox RFC](2026-07-06-sandbox.md)) is the session's standing choice, the mode's `access` is a cap that rides the mode — and the cap is a clamp, never a write. `resolveMode` folds `override ?? default` and the clamp takes the ladder minimum at read time, per call, so the two switch in any order without interference: a knob flipped during plan stays logged but capped, exiting plan uncovers it unchanged, and there is no restore step for a crash to strand. The log attributes each axis to its own event — a clamped call's width to the standing `mode/set`, a knob change to its `bash/sandbox-mode` event.
**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs and belong to ACP's `session/set_config_option`; the division this proposal pins is picker-to-modes / knobs-to-config-options. The in-flight sandbox branch already ships both knobs as config-option selects and its feature-matrix stance records session modes as deliberately unmodeled — the one overlap between the two stacks; landing the picker supersedes that stance, and whichever side lands second amends the matrix rows. A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs and belong to ACP's `session/set_config_option`; the division this proposal pins is picker-to-modes / knobs-to-config-options, recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md) now that both this stack's picker and the sandbox stack's config options are landed. A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered).
## Prior art
@@ -171,7 +171,7 @@ A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini C
The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex — whose plan feature itself is `/plan` — fills its list with its approval presets (read-only / agent / full-access). This is the surface [the ACP feature matrix](../../../../packages/ui/acp/acp-feature-support.md) records as the gap, and what sizes the vocabulary as named modes rather than a flag.
The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. Each hole closes structurally here, but only because the mode is logged session state rather than plugin-private memory: per-agent folded state replaces the contested global list, the hard gate closes the prompt-only hole, and a log-only non-surface event that compaction cannot shadow makes the re-injection hack unnecessary.
The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only hole closes where an enforcer exists: the shell is confined by the kernel-backed sandbox clamp, not by text; outside the shell the harness currently accepts guidance-only restraint, deliberately and temporarily ([FAQ](#faq)), rather than shipping the name-list non-fix.
## Alternatives considered
@@ -181,7 +181,9 @@ The ecosystems that leave modes to convention show the failure shapes to avoid.
**Loop-owned mode state.** Rejected on the standing rule (plugins, not loop changes): every hook the feature needs — assemble, pre-execute, turn boundaries, session events — is already a documented seam, so a loop edit would buy nothing but coupling.
**Prompt-only plan mode (no hard gate).** The Pi failure shape ([Prior art](#prior-art)): filtering schemas (or asking nicely) does not stop a dispatch of a still-registered tool. The pre-execute gate is the enforcement layer; the filter is UX and cache hygiene.
**A per-mode tool allowlist with a deny-by-default gate (the first shipped shape).** Removed before release. A hand-maintained name list re-declares a per-TOOL fact (its effects) per MODE: it must enumerate every tool the deployment composes — MCP servers and future registrations included — and it rots silently as tools arrive (a new read-only tool is blocked until someone edits every mode; the author burden lands on whoever knows the mode, not whoever knows the tool). It also over-promises: the list looks like a security boundary while the real boundary for anything non-shell does not exist yet. The replacement scopes enforcement to the one real enforcer (the sandbox `access` cap) and parks the general dimension on effects self-declaration ([Deferred](#deferred)). The interim consequence — outside the shell, plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately as a pre-release trade, priced in [Consequences](#consequences).
**Prompt-only bash confinement.** The Pi failure shape ([Prior art](#prior-art)) applied to the one surface that matters most: asking nicely does not stop a shell command. The `access` cap is kernel-backed enforcement (`bash/resolve-mode` clamp over the sandbox executor), never text.
**Runtime-only mode (UI- or bridge-local, unlogged).** Resume and fork would silently drop the mode, and the header deltas a mode causes would have no attributable cause in the log. Logged state is what makes the mode auditable and restorable for free.
@@ -191,7 +193,7 @@ The ecosystems that leave modes to convention show the failure shapes to avoid.
**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition.
**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: waterfall listeners compose by construction, and the deny-by-default hard gate makes filter-order races non-exploitable. Formalize only if real conflicts appear.
**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: waterfall listeners compose by construction, and the narrow surface this plugin filters (the exit tool, an unhonorable-cap trio) makes filter-order races non-exploitable. Formalize only if real conflicts appear.
**Exit approval through the approval seam (a `{ kind: 'ask' }` gate decision).** The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (`allowed-once`/`rejected`), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's `ask` vocabulary stays available to deployments that want one there.
@@ -204,10 +206,10 @@ What holds now, pinned by the unit, protocol, and snapshot tiers:
- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by the matching `request/header` event (delta or fallback snapshot) on the next step with the dev invariant green throughout.
- A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result.
- In the default mode the plugin is invisible: assemblies are byte-identical with and without `dsh-mode` loaded, and every pre-existing snapshot golden is unchanged.
- In plan mode the filtered schemas and mode section reach both the wire request and the logged header; a call to a registered-but-filtered mutating tool is denied at `tools/pre-execute` with the mode-naming reason.
- Under a confining executor the bash trio stays available in plan mode with every call's sandbox resolution clamped to `read-only` (the `bash/resolve-mode` waterfall); the session's sandbox knob is never written by the mode and re-emerges intact on exit; a `sandbox_permissions` escalation inside a capped mode is denied at the gate. Without a confining executor the trio is hidden and denied.
- Mode definitions (allowlist, section text, `access` cap) are changeable from `cordis.yml` with no code edit; an unknown mode name fails validation loudly at `set()` time.
- `exit_plan_mode`'s approve path flips the mode and restores the full toolset on the next step; the keep-planning path returns the corrective `isError` carrying the user's feedback and stays in plan mode; the ACP `session/set_mode` round-trip updates `current_mode_update`, and the exit review prompts through each surface's user-interaction provider.
- In plan mode the guidance section and the `exit_plan_mode` tool reach both the wire request and the logged header; entering plan logs the fallback header snapshot (front-of-list insertion), the approved exit logs the pure-removal header-delta.
- Under a confining executor the bash trio stays available in plan mode with every call's sandbox resolution clamped to `read-only` (the `bash/resolve-mode` waterfall); the session's sandbox knob is never written by the mode and re-emerges intact on exit; a `sandbox_permissions` escalation inside a capped mode is denied at the guard. Without a confining executor the trio is hidden and denied.
- Mode definitions (section text, `access` cap) are changeable from `cordis.yml` with no code edit; an unknown mode name fails validation loudly at `set()` time, an unknown definition key at load.
- `exit_plan_mode`'s approve path flips the mode and lifts the plan constraints on the next step; the keep-planning path returns the corrective `isError` carrying the user's feedback and stays in plan mode; the ACP `session/set_mode` round-trip updates `current_mode_update`, and the exit review prompts through each surface's user-interaction provider.
- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row.
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. The mode filter prepends, so only a listener that ALSO prepends after `dsh-mode` loads can wrap outside it and re-widen filtered schemas — the one shipped instance is the structured runtime's per-spawn final-assembly wrapper, whose `structured_output` is on the plan allowlist precisely so the filter, that wrapper, and the gate agree; for any future such listener the hard gate keeps a re-widened tool non-executable, and the residual cost is cosmetic (the model sees a tool it cannot use), accepted rather than mechanized. Plan mode's bash power is exactly as real as the composition: under `dsh-bash-local` (or no executor) the trio is withheld in plan — hidden and denied, because an unhonorable cap must not be offered — and `subagent` stays excluded until mode inheritance lands; a deployment that accepts the risk can still define an uncapped mode that allowlists bash. The conditional-trio rule names the three bash tools rather than deriving them from tool metadata the generalization is the effects self-declaration item in [Deferred](#deferred). The ACP mode surface carries both this stack's picker and the sandbox stack's config options under the picker-to-modes / knobs-to-config-options division pinned in the [FAQ](#faq), recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md). The ACP spec's draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates to a config-option select mechanically — the mode state and both enforcement layers are wire-agnostic — accepted.
The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). Every mode transition is a logged header change and therefore a prefix-cache reset at the provider — inherent, visible in per-step usage, and an argument against mode-flapping UIs, not against the design. **Outside the shell, a non-default mode restrains mutations by guidance alone** until effects self-declaration lands: a model that ignores the section CAN write during plan — the review moment, the session log (every call attributable under the plan-shaped header), and the fs stack's own policies are the audit surface meanwhile; this is the deliberate pre-release trade archived in [Alternatives considered](#alternatives-considered), with the effects item in [Deferred](#deferred) as its restart trigger. The mode filter prepends and hides only the exit tool and an unhonorable-cap bash trio; a listener that ALSO prepends after `dsh-mode` loads could re-show them — the cap guard keeps an unconfined bash non-executable either way, and a re-shown exit tool self-rejects outside plan, so the residual is cosmetic. Plan mode's shell power is exactly as real as the composition: under `dsh-bash-local` (or no executor) the trio is withheld in plan — an unhonorable cap must not be offered — and the guard names the three bash tools rather than deriving them from tool metadata, the same generalization gap the effects item closes. The ACP mode surface carries both this stack's picker and the sandbox stack's config options under the picker-to-modes / knobs-to-config-options division pinned in the [FAQ](#faq), recorded in [the feature matrix](../../../../packages/ui/acp/acp-feature-support.md). The ACP spec's draft v2 direction reportedly slates session modes for removal in favor of config options; if that lands, the picker migrates to a config-option select mechanically — the mode state and every policy surface are wire-agnostic — accepted.

View File

@@ -126,7 +126,7 @@ Registered by the tool registry itself under `mode: code` / `mode: both` (see th
### `exit_plan_mode`
Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (the full toolset returns on your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
```json
{