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

@@ -1,9 +1,9 @@
# mode/ — session-mode policy family
Session modes: named, logged, per-agent policy states, with **plan mode** as the first shipped definition. A single **product** package — there is no interface/implementation seam here, because a mode's variable parts are config values (allowlist, section text), not swappable implementations.
Session modes: named, logged, per-agent policy states, with **plan mode** as the first shipped definition. A single **product** package — there is no interface/implementation seam here, because a mode's variable parts are config values (section text, the `access` cap), not swappable implementations.
| Package | Role | ctx key |
|---|---|---|
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the soft layer (assemble filter + `mode:policy` section), the hard layer (`tools/pre-execute` deny-by-default gate), and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, the `access` cap (a `bash/resolve-mode` clamp plus the cap-derived bash guards), and the model-facing `exit_plan_mode` review tool | `ctx.modes` |
The mode in force is a pure function of the session log (`SessionEventMap['mode/set']`, last one wins), so resume and fork restore it with no extra machinery; the default mode is the absence of policy, keeping the plugin invisible until a mode is set. UIs read flips off `session/event`: the [stdio app](../ui/stdio-agent) exposes `/mode`, the [ACP bridge](../ui/acp) maps the vocabulary to the session-mode picker. RFC: [plan mode](../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -1,26 +1,20 @@
# @deepseek-ai/dsh-mode
Session modes: named, logged, per-agent policy states. **Plan mode** is the first shipped definition — the agent explores and designs under a read-only tool policy, produces a reviewable plan, and crosses back into full authority through an explicit review.
Session modes: named, logged, per-agent policy states. **Plan mode** is the first shipped definition — the agent explores and designs under a read-only stance, produces a reviewable plan, and crosses back into full authority through an explicit review.
## The mode state is a session event
`mode/set` (`{ mode: string }`) is a log-only, non-surface `SessionEventMap` member with whole-value-replace semantics; the pure `foldMode(events)` returns the mode in force (the last `mode/set`, else `default`). Because the log is the fact channel, resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event` — there is no live mirror.
The `default` mode is the absence of policy: no section, no filtering, no gate. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads this plugin.
The `default` mode is the absence of policy: no section, no cap. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads this plugin.
## Two layers of enforcement
## What a mode enforces
**Soft — what the model sees.** A `system-prompt/assemble` listener filters the returned assembly's tools down to the mode's allowlist and the `mode:policy` section (order 50) renders the mode's guidance text. Every transition therefore surfaces as an attributable `request/header` event on the next step (a delta when expressible; adding `exit_plan_mode` resorts the canonical tool list, which the delta encoding cannot express, so entering plan mode logs the full fallback snapshot). The `exit_plan_mode` tool is visible IFF the folded mode is `plan` — on the wire and, under the registry's Code Mode, in the `tools:sdk` section alike.
**The guidance section.** A `system-prompt/assemble` listener renders the mode's `section` text as the `mode:policy` section (order 50) while the mode is in force, and shows the `exit_plan_mode` tool IFF the folded mode is `plan` — on the wire and, under the registry's Code Mode, in the `tools:sdk` section alike. Every transition therefore surfaces as an attributable `request/header` event on the next step (entering plan adds the exit tool, a front-of-list insertion with no delta form → the full fallback snapshot; the approved exit removes exactly that tool and the section, a pure removal → one `request/header-delta`).
**Hard — what can run.** A `tools/pre-execute` listener denies, deny-by-default against the same allowlist, any call the mode does not permit — a hallucinated call to a still-registered (or freshly re-widened) tool cannot run. Agent-less executions and the default mode pass through; the gate judges by the LOGGED mode only, never a pending intent. `run_code` passes both layers as a TRANSPORT: under the registry's Code Mode it is the only wire tool, every bridged sub-call re-enters this gate with the same agent, and the `tools:sdk` section is re-rendered under the mode's visibility rule — the allowlist governs each capability individually and the prompt documents exactly the callable set.
**The `access` cap.** A definition may declare `access` — the widest sandbox access shell commands run under while the mode is in force, on the `SANDBOX_MODES` ladder from [`@deepseek-ai/dsh-bash`](../../bash/bash/) (`read-only` | `workspace-write` | `danger-full-access`). The built-in `plan` ships `access: 'read-only'`: exploration commands run for real, and a write is denied by the sandbox itself. The cap is a **clamp, not a switch**: a `bash/resolve-mode` waterfall listener returns `min(resolved, access)` on the ladder. The session's own sandbox-mode knob (`bash/sandbox-mode` events) is never written — the two folds compose at read time, so the knob and the mode switch in any order without disturbing each other, and a knob flipped during plan re-emerges intact on exit. Two guards ride with a declared cap: the bash trio (`bash`/`bash_output`/`bash_kill`) is hidden and denied while no confining executor is mounted (`ctx.bash.sandboxMode` unset — an unconfinable shell cannot honor the cap), and a `bash` call carrying `sandbox_permissions` is denied outright (the cap would otherwise be pierceable mid-mode by one approval prompt). A mode without `access` gets neither guard.
## The `access` cap
A definition may declare `access` — the widest sandbox access shell commands run under while the mode is in force, using the `SANDBOX_MODES` ladder from [`@deepseek-ai/dsh-bash`](../../bash/bash/) (`read-only` | `workspace-write` | `danger-full-access`). The built-in `plan` ships `access: 'read-only'`, which is what lets it keep `bash` on the allowlist: exploration commands run for real, and a write is denied by the sandbox itself.
The cap is a **clamp, not a switch**: a `bash/resolve-mode` waterfall listener returns `min(resolved, access)` on the ladder. The session's own sandbox-mode knob (`bash/sandbox-mode` events) is never written — the two folds compose at read time, so the knob and the mode switch in any order without disturbing each other, and a knob flipped during plan re-emerges intact on exit. Both derive from the same log, so resume restores the composition for free.
Two consequences ride with a declared cap. The bash trio (`bash`/`bash_output`/`bash_kill`) becomes CONDITIONAL: both policy layers admit it only while a confining executor is mounted (`ctx.bash.sandboxMode` set — an unconfinable shell cannot honor the cap, so under `dsh-bash-local` the trio is hidden and denied like any non-allowlisted tool). And sandbox ESCALATION is denied outright: a `bash` call carrying `sandbox_permissions` gets a deny that points the model at putting the widened step in the plan — the cap would otherwise be pierceable mid-mode by one approval prompt. A mode that allowlists `bash` WITHOUT `access` is the deployment's explicit choice of an uncapped shell in that mode; neither rule applies.
**Deliberately absent: a tool allow/deny list.** Which tools a mode admits is an effects question — a per-tool read-only/mutating classification the harness does not yet have. Until tool definitions declare their effects (the plan-mode RFC's deferred item), a mode's non-shell restraint is the section's guidance and its shell restraint is the sandbox; the config vocabulary is exactly `{ section, access? }`, and an unknown key (a `tools` list included) fails loud at load.
## `ctx.modes`
@@ -30,7 +24,7 @@ Two consequences ride with a declared cap. The bash trio (`bash`/`bash_output`/`
## `exit_plan_mode`
The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve records the switch back to `default` as a silent boundary-applied pending intent (flushed at this step's end — the gate stays plan-mode for any remaining call of the same assistant response) and the next step's assembly restores the full toolset; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue.
The model-facing exit tool. Its single required argument is the plan text — a durable, replayable log artifact riding the ordinary `tool/call` event. `execute` re-checks the folded mode, then conducts the review over the user-interaction seam (`ctx.get('userInteraction')`, opportunistic): one single-select question — Approve, or Keep planning — with the free-text channel open. Approve records the switch back to `default` as a silent boundary-applied pending intent (flushed at this step's end — the plan policy, the sandbox clamp included, keeps holding for any remaining call of the same assistant response) and the next step runs unclamped; every other outcome (keep-planning with the user's feedback verbatim, an aborted question, no provider) returns the corrective `isError` and the mode stays `plan`. `presentCall` renders a `generic` card titled by the plan's first heading with the plan markdown as content; over ACP the review rides the same elicitation flow as `ask_user_question`, in the terminal the stdio provider's prompt queue.
## Config
@@ -42,10 +36,9 @@ The model-facing exit tool. Its single required argument is the plan text — a
plan:
section: |
You are in 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
```
Definitions are validated at load (`resolveConfig`): the built-in `plan` (the read-only allowlist above — the ask/report channels `ask_user_question`/`structured_output` included, the bash trio conditional on a confining executor via `access: read-only`, `subagent` excluded) merges unless overridden, `default` is rejected as a key, an `access` outside the `SANDBOX_MODES` ladder throws, and allowlists may name not-yet-registered tools (registration is dynamic). An unknown name fails loudly at `set()` time.
Definitions are validated at load (`resolveConfig`): the built-in `plan` (the shipped guidance section plus `access: read-only`) merges unless overridden, `default` is rejected as a key, an `access` outside the `SANDBOX_MODES` ladder throws, and any other key — a `tools` list included — fails loud. An unknown mode name fails loudly at `set()` time.
RFC: [plan mode](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -1,18 +1,22 @@
/**
* Session modes: named, logged, per-agent policy states, with **plan mode** as
* the first shipped definition. A mode names which tools stay visible (the
* soft layer, a `system-prompt/assemble` filter plus a guidance section) and
* which may run (the hard layer, a deny-by-default `tools/pre-execute` gate);
* a mode may also declare `access` — a cap the bash seam's per-call sandbox
* resolution is clamped to while the mode is in force (a `bash/resolve-mode`
* listener), composing with the session's own sandbox knob without ever
* writing it. The mode IN FORCE for an agent is session state, folded from
* its log (`mode/set`, last one wins), so resume and fork restore it for free.
* the first shipped definition. A mode carries the guidance section the model
* sees while it is in force and, optionally, an `access` cap — the widest
* sandbox access shell commands run under, made real as a `bash/resolve-mode`
* clamp (composing with the session's own sandbox knob without ever writing
* it) plus two cap-derived guards: the bash tools are withheld when no
* confining executor can honor the cap, and sandbox escalation is denied
* while the cap holds. There is deliberately NO general tool allow/deny
* list: which tools a mode admits is an effects question, parked until tool
* definitions can declare their effects (the plan-mode RFC's deferred item) —
* until then a mode's non-shell restraint is the section's guidance, and its
* shell restraint is the sandbox. The mode IN FORCE for an agent is session
* state, folded from its log (`mode/set`, last one wins), so resume and fork
* restore it for free.
*
* The default mode is the absence of policy: no section, no filtering, no
* gate. An agent that never sees a `mode/set` behaves byte-identically to a
* deployment that never loads this plugin, so it is safe to compose
* unconditionally.
* The default mode is the absence of policy: no section, no cap. An agent
* that never sees a `mode/set` behaves byte-identically to a deployment that
* never loads this plugin, so it is safe to compose unconditionally.
*
* User flips go through {@link ModesService.set}: every session event is
* turn-enclosed and an idle agent has no open turn, so `set()` records a
@@ -86,15 +90,14 @@ export const PLAN_MODE = 'plan'
export const EXIT_PLAN_MODE = 'exit_plan_mode'
/**
* 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
@@ -125,36 +128,27 @@ export interface ResolvedModes {
const PLAN_SECTION
= 'You are in plan mode: a read-only planning state. Explore, analyze, and design; '
+ 'do not attempt to modify anything — mutating tools are not available and calls '
+ 'to them are denied. Where a bash tool is present it runs under a read-only '
+ 'sandbox: commands that only read work normally, while a command that writes is '
+ 'denied by the sandbox — that denial marks the edge of plan mode rather than a '
+ 'bug, and sandbox escalation is not offered here; put the step in the plan for '
+ 'after approval instead. When a decision or a missing detail blocks the plan, ask the '
+ 'do not modify anything yet — edits and other side effects belong in the plan and '
+ 'run after its approval, not in this mode. Where a bash tool is present it runs '
+ 'under a read-only sandbox: commands that only read work normally, while a command '
+ 'that writes is denied by the sandbox — that denial marks the edge of plan mode '
+ 'rather than a bug, and sandbox escalation is not offered here; put the step in '
+ 'the plan for after approval instead. When a decision or a missing detail blocks the plan, ask the '
+ 'user through the ask_user_question tool where it is available. A finished plan '
+ 'is delivered by calling exit_plan_mode — that call is what puts it in front of '
+ 'the user for review, so prefer it over pasting the plan as a plain reply or '
+ 'asking the user to switch modes themselves. If exit_plan_mode is unavailable or '
+ 'its review fails, ask the user to switch the session out of plan mode instead '
+ 'of retrying denied tools.'
+ 'of pressing on.'
/**
* The three bash tools an `access` cap conditions on a confining executor:
* `bash` runs commands under the capped sandbox; `bash_output`/`bash_kill`
* only observe and stop tasks that ran under it.
* only observe and stop tasks that ran under it. Both policy layers withhold
* the trio when no mounted executor can honor the cap.
*/
const BASH_FAMILY = ['bash', 'bash_output', 'bash_kill']
// 'structured_output' is a structured subagent child's result channel (pure
// reporting, the ask/exit class of read-only-safe): its runtime re-injects the
// schema into the FINAL assembly from an outermost per-spawn listener, so
// allowlisting is what keeps the soft filter, that re-injection, and the hard
// gate telling one consistent story when such a child runs in plan mode.
// The bash trio is allowlisted CONDITIONALLY: plan's read-only `access` cap
// can only be honored by a confining executor, so both policy layers admit
// these three only while `ctx.bash.sandboxMode` proves one is mounted.
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', ...BASH_FAMILY, EXIT_PLAN_MODE]
/** The review question's approve option label — the answer item is matched by it. */
const APPROVE_LABEL = 'Approve'
@@ -164,7 +158,7 @@ const KEEP_PLANNING_LABEL = 'Keep planning'
const EXIT_DESCRIPTION
= '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 '
+ '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.'
/** The plan's first markdown heading (any level), or `undefined` when it has none. */
@@ -191,7 +185,7 @@ function hasEscalationArgs(args: unknown): boolean {
*/
export function resolveConfig(config: ModeConfig): ResolvedModes {
const definitions = new Map<string, ModeDefinition>()
definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS], access: 'read-only' })
definitions.set(PLAN_MODE, { section: PLAN_SECTION, access: 'read-only' })
for (const [name, definition] of Object.entries(config.modes ?? {})) {
if (name === DEFAULT_MODE) {
throw new Error(`ModeConfig: "${DEFAULT_MODE}" is reserved (the absence of policy) and cannot be defined`)
@@ -199,15 +193,18 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
if (typeof definition.section !== 'string') {
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
}
if (!Array.isArray(definition.tools) || definition.tools.some(tool => typeof tool !== 'string')) {
throw new Error(`ModeConfig: mode "${name}" needs a \`tools\` array of tool names`)
}
if (definition.access !== undefined && !SANDBOX_MODES.includes(definition.access)) {
throw new Error(`ModeConfig: mode "${name}" has unknown access ${JSON.stringify(definition.access)} — one of: ${SANDBOX_MODES.join(', ')}`)
}
// Unknown keys fail loud rather than silently shaping nothing — the
// definition vocabulary is exactly { section, access? } (a tool
// allow/deny list is deliberately NOT part of it; see the module doc).
const unknown = Object.keys(definition).filter(key => key !== 'section' && key !== 'access')
if (unknown.length > 0) {
throw new Error(`ModeConfig: mode "${name}" has unknown key(s) ${unknown.join(', ')} — a definition is { section, access? }`)
}
definitions.set(name, {
section: definition.section,
tools: [...definition.tools],
...definition.access !== undefined ? { access: definition.access } : {},
})
}
@@ -298,10 +295,9 @@ export class ModesService extends Service {
// prepend: the filter wraps OUTSIDE every append-registered listener
// regardless of load order, so their post-next() additions are filtered
// too. A listener that ALSO prepends after this plugin loads (the
// structured runtime's per-spawn wrapper) still wins the wrap — for that
// one the allowlist carries the contract, and the hard gate covers
// execution either way.
// too. What it hides is deliberately narrow: the exit tool outside plan
// mode, and the bash trio when an access cap cannot be honored — there is
// no general allowlist (see the module doc).
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const result = await next()
const agent = context.agent
@@ -318,26 +314,18 @@ export class ModesService extends Service {
rerenderSdk(result, name => name !== EXIT_PLAN_MODE)
return result
}
const allowed = new Set(active.definition.tools)
// An access-capped mode exposes the bash trio only while a confining
// executor is mounted — advertised tools stay honest about the cap.
// Read per assembly via ctx.get (never static inject): the executor is
// optional to this plugin and may swap at runtime.
const bashUsable = active.definition.access === undefined || ctx.get('bash')?.sandboxMode !== undefined
const visible = (name: string): boolean =>
allowed.has(name)
&& (name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
(name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
&& (bashUsable || !BASH_FAMILY.includes(name))
// run_code is a TRANSPORT, not a capability: under the registry's Code
// Mode it is the only wire tool (filtering it would leave the model
// with nothing, not even the exit), and every bridged sub-call
// re-enters tools/pre-execute with the same agent, where the allowlist
// governs each capability individually.
result.tools = result.tools.filter(tool => visible(tool.name) || tool.name === RUN_CODE_NAME)
result.tools = result.tools.filter(tool => visible(tool.name))
// Code Mode's soft surface is the SDK section, not the wire schemas —
// section text resolves in assemble's base, so the outermost wrapper
// re-renders it under the same visibility rule the wire filter applies.
// Without this the prompt would document bindings the gate denies.
rerenderSdk(result, visible)
return result
}, { prepend: true })
@@ -357,37 +345,36 @@ export class ModesService extends Service {
index === sdkIndex ? { ...section, text: sdkText } : section)
}
// The cap-derived guard — the ONLY execution gating this plugin does
// (there is no general tool gate; see the module doc). Two rules, both
// riding a declared `access`: the bash trio cannot run when no confining
// executor can honor the cap, and a `bash` call carrying the escalation
// field cannot pierce the cap mid-mode.
ctx.on('tools/pre-execute', (exec, next): Promise<PreToolDecision> => {
if (exec.agent === undefined) return next()
const active = this.activeDefinition(exec.agent.session)
if (active === undefined) return next()
// Transport pass-through: a run_code program's every tool call is
// serialized back through ToolRegistry.execute() with the same agent,
// so each sub-call is judged here individually — gating the wrapper
// would only remove the vehicle, not widen or narrow any capability.
if (exec.name === RUN_CODE_NAME) return next()
// The bash trio is conditional under an access cap: without a confining
// executor the cap cannot be honored, so the trio reads as not
// allowlisted — the same absence the assemble filter's hiding implies.
const capped = active.definition.access !== undefined && BASH_FAMILY.includes(exec.name)
const bashUsable = !capped || ctx.get('bash')?.sandboxMode !== undefined
if (active.definition.tools.includes(exec.name) && bashUsable) {
// A capped mode admits `bash` but no widening: escalation would pierce
// the cap mid-mode. Denied HERE, before tool-bash's escalation path
// would treat the clamped resolution as a legitimate baseline and
// raise the approval prompt.
if (capped && exec.name === 'bash' && hasEscalationArgs(exec.arguments)) {
return Promise.resolve({
kind: 'deny',
reason: `sandbox escalation is not available in ${active.name} mode — the sandbox stays ${active.definition.access} while it is in force; put the wider-access step in the plan for after approval`,
})
}
return next()
const access = active?.definition.access
if (active === undefined || access === undefined) return next()
if (!BASH_FAMILY.includes(exec.name)) return next()
if (ctx.get('bash')?.sandboxMode === undefined) {
// Unhonorable cap: the trio is hidden by the filter, and a call that
// arrives anyway (hallucinated, or re-widened by a foreign assemble
// listener) is refused — never run unconfined under a capped mode.
return Promise.resolve({
kind: 'deny',
reason: `tool "${exec.name}" is not available in ${active.name} mode: its ${access} sandbox cap needs a sandboxing bash executor, and none is mounted`,
})
}
const reason = active.name === PLAN_MODE
? `tool "${exec.name}" is not available in plan mode; continue planning and present your plan with ${EXIT_PLAN_MODE} when ready`
: `tool "${exec.name}" is not available in "${active.name}" mode`
return Promise.resolve({ kind: 'deny', reason })
if (exec.name === 'bash' && hasEscalationArgs(exec.arguments)) {
// Denied HERE, before tool-bash's escalation path would treat the
// clamped resolution as a legitimate baseline and raise the approval
// prompt.
return Promise.resolve({
kind: 'deny',
reason: `sandbox escalation is not available in ${active.name} mode — the sandbox stays ${access} while it is in force; put the wider-access step in the plan for after approval`,
})
}
return next()
})
// The access cap made real: clamp the bash seam's per-call resolution to
@@ -426,7 +413,7 @@ export class ModesService extends Service {
header: 'Plan review',
question: 'Approve this plan and leave plan mode?',
options: [
{ label: APPROVE_LABEL, description: 'Leave plan mode; the full toolset returns on the next step.' },
{ label: APPROVE_LABEL, description: 'Leave plan mode; the plan is carried out from the next step.' },
{ label: KEEP_PLANNING_LABEL, description: 'Stay in plan mode; feedback goes back to the model.' },
],
}],
@@ -444,14 +431,15 @@ export class ModesService extends Service {
}
// A boundary-applied switch, NOT a direct append: the loop may still
// execute further tool calls from the SAME assistant response after
// this one, and they were requested under the plan-shaped header — a
// same-batch exit_plan_mode + write pair must not smuggle the write
// past the gate. The flush at this step's end appends the mode/set
// (still in-turn), so the next step's assembly widens; narrate: false —
// this result IS the narration.
// this one, and they were requested under the plan-shaped header — so
// the plan policy (the read-only sandbox clamp, the exit tool's
// visibility) must keep holding for that whole batch. The flush at
// this step's end appends the mode/set (still in-turn), so the next
// step's assembly widens; narrate: false — this result IS the
// narration.
this.pendingIntents.set(agent.session, { mode: DEFAULT_MODE, narrate: false })
const note = item.custom === undefined || item.custom === '' ? '' : ` User note: ${item.custom}`
return [{ type: 'text', text: `Plan approved — plan mode exited; the full toolset returns on your next step.${note}` }]
return [{ type: 'text', text: `Plan approved — plan mode exited; carry out the plan starting with your next step.${note}` }]
},
presentCall: args => ({
card: 'generic',

View File

@@ -13,9 +13,10 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
/**
* Full-loop integration: a scripted mock model drives the REAL mode plugin
* through the agent loop — the pending-intent flush at the turn boundary, the
* assembly the soft layer filters, the `request/header`/`request/header-delta`
* trail every transition leaves, and the hard gate's deny. Only the model is
* mocked; the loop, the session log, and the plugin are real.
* assembly the soft layer shapes (the exit tool + mode section), and the
* `request/header`/`request/header-delta` trail every transition leaves.
* Only the model is mocked; the loop, the session log, and the plugin are
* real.
*/
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
@@ -62,10 +63,10 @@ function findEvent<T extends SessionEvent['type']>(
}
describe('plan mode through the agent loop', () => {
it('seeds plan mode from AgentOptions: the FIRST header is already plan-shaped and the gate denies a write', async () => {
it('seeds plan mode from AgentOptions: the FIRST header is already plan-shaped, and a non-shell call is guidance-constrained only', async () => {
const adapter = new MockAdapter([
toolCallResponse('call-1', 'write', {}, 'Trying to write anyway.'),
textResponse('Back to planning.'),
toolCallResponse('call-1', 'write', {}, 'Writing during plan.'),
textResponse('Noted in the plan.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('it-plan-seed'), { model: 'mock', mode: PLAN_MODE })
@@ -78,16 +79,19 @@ describe('plan mode through the agent loop', () => {
const header = findEvent(log, 'request/header')
expect(modeSet.seq).toBeLessThan(header.seq)
expect(header.data.reason).toBe('initial')
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(header.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(header.data.header.system).toContain('plan mode')
// No general tool gate: the write RUNS — plan's non-shell restraint is
// the section's guidance (the shell restraint is the sandbox clamp,
// pinned in mode.spec). The mode itself stays plan throughout.
const result = findEvent(log, 'tool/result')
expect(result.data.isError).toBe(true)
expect(result.data.isError).toBe(false)
expect(foldMode(log)).toBe(PLAN_MODE)
expect(log.some(event => event.type === 'context/message')).toBe(false)
})
it('a user flip between turns lands at the boundary: one notice and the widening header delta', async () => {
it('a user flip between turns lands at the boundary: one notice and the plan-shaped fallback header', async () => {
const adapter = new MockAdapter([
textResponse('First turn, default mode.'),
textResponse('Second turn, plan mode.'),
@@ -110,12 +114,12 @@ describe('plan mode through the agent loop', () => {
expect(findEvent(log, 'context/message').data.content).toEqual([
{ type: 'text', text: 'The user switched this session to plan mode.' },
])
// The narrowing header change is logged as a FULL fallback snapshot, not a
// delta: adding exit_plan_mode reorders the canonical tool list (it sorts
// The header change is logged as a FULL fallback snapshot, not a delta:
// adding exit_plan_mode reorders the canonical tool list (it sorts
// first), and a pure reordering is inexpressible in the delta encoding.
const second = findEvent(log, 'request/header', 'last')
expect(second.data.reason).toBe('fallback')
expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(second.data.header.tools?.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'write'])
expect(second.data.header.system).toContain('plan mode')
})
})

View File

@@ -75,42 +75,42 @@ function execute(ctx: Context, name: string, agent?: Agent) {
}
describe('resolveConfig', () => {
it('merges the built-in plan definition with the read-only allowlist', () => {
it('merges the built-in plan definition: guidance section + read-only access cap, no tool list', () => {
const resolved = resolveConfig({})
const plan = resolved.definitions.get(PLAN_MODE)
expect(plan?.tools).toEqual(['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', 'bash', 'bash_output', 'bash_kill', EXIT_PLAN_MODE])
expect(plan).toEqual({ section: plan?.section, access: 'read-only' })
expect(plan?.section).toContain('plan mode')
expect(plan?.access).toBe('read-only')
})
it('lets config override plan and add further modes', () => {
const resolved = resolveConfig({ modes: {
plan: { section: 'custom plan', tools: ['read'] },
review: { section: 'review', tools: ['read', 'write'] },
plan: { section: 'custom plan' },
review: { section: 'review', access: 'workspace-write' },
} })
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan', tools: ['read'] })
expect(resolved.definitions.get('review')).toEqual({ section: 'review', tools: ['read', 'write'] })
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan' })
expect(resolved.definitions.get('review')).toEqual({ section: 'review', access: 'workspace-write' })
})
it('rejects the reserved default key loudly', () => {
expect(() => resolveConfig({ modes: { default: { section: '', tools: [] } } }))
expect(() => resolveConfig({ modes: { default: { section: '' } } }))
.toThrow('"default" is reserved')
})
it('rejects a malformed definition loudly', () => {
expect(() => resolveConfig({ modes: { bad: { section: 5, tools: [] } as unknown as { section: string; tools: string[] } } }))
expect(() => resolveConfig({ modes: { bad: { section: 5 } as unknown as { section: string } } }))
.toThrow('needs a string `section`')
expect(() => resolveConfig({ modes: { bad: { section: '', tools: 'read' } as unknown as { section: string; tools: string[] } } }))
.toThrow('needs a `tools` array')
expect(() => resolveConfig({ modes: { bad: { section: '', tools: [7] } as unknown as { section: string; tools: string[] } } }))
.toThrow('needs a `tools` array')
// Unknown keys fail loud — a tool allow/deny list is deliberately not
// part of the vocabulary, and a config still carrying one must not be
// silently accepted as if it shaped anything.
expect(() => resolveConfig({ modes: { bad: { section: '', tools: ['read'] } as unknown as { section: string } } }))
.toThrow('unknown key(s) tools — a definition is { section, access? }')
})
it('validates access against the sandbox-mode ladder', () => {
expect(() => resolveConfig({ modes: { locked: { section: 's', tools: [], access: 'sealed' as never } } }))
expect(() => resolveConfig({ modes: { locked: { section: 's', access: 'sealed' as never } } }))
.toThrow('unknown access "sealed" — one of: read-only, workspace-write, danger-full-access')
const resolved = resolveConfig({ modes: { locked: { section: 's', tools: ['bash'], access: 'workspace-write' } } })
expect(resolved.definitions.get('locked')).toEqual({ section: 's', tools: ['bash'], access: 'workspace-write' })
const resolved = resolveConfig({ modes: { locked: { section: 's', access: 'workspace-write' } } })
expect(resolved.definitions.get('locked')).toEqual({ section: 's', access: 'workspace-write' })
})
})
@@ -135,7 +135,7 @@ describe('foldMode', () => {
describe('ctx.modes: list/get/set', () => {
it('lists default first, then the configured definitions', async () => {
const ctx = await setup({ modes: { review: { section: 's', tools: [] } } })
const ctx = await setup({ modes: { review: { section: 's' } } })
expect(ctx.modes.list()).toEqual([DEFAULT_MODE, PLAN_MODE, 'review'])
})
@@ -318,38 +318,38 @@ describe('the soft layer', () => {
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('')
})
it('filters plan-mode tools to the allowlist and renders the mode section', async () => {
it('keeps the full toolset in plan mode, adds the exit tool, and renders the mode section', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write', 'todo_write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write'])
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'todo_write', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toContain('plan mode')
})
it('drops exit_plan_mode outside plan mode even when a custom allowlist names it', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing', tools: ['read', EXIT_PLAN_MODE] } } })
it('drops exit_plan_mode outside plan mode (custom modes never see it)', async () => {
const ctx = await setup({ modes: { review: { section: 'reviewing' } } })
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['read'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['read', 'write'])
expect(assembly.sections.find(section => section.name === 'mode:policy')?.text).toBe('reviewing')
})
it('filters additions from an append-registered final-assembly mutator, regardless of load order', async () => {
// A foreign listener that post-processes await next() and was registered
// BEFORE dsh-mode loaded: under append ordering it would wrap OUTSIDE the
// filter and its re-added tool would leak into the plan-mode header. The
// filter registers with prepend, so it wraps outside every
// append-registered listener and filters their additions too.
it('leaves foreign post-next() additions alone in plan mode (no general tool filtering)', async () => {
// A foreign listener that post-processes await next(): the mode filter
// wraps outside it (prepend) but hides only the exit tool outside plan
// and the bash trio under an unhonorable cap — a foreign addition
// survives, because which tools a mode admits is deliberately not this
// plugin's decision (the effects question stays parked; module doc).
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const final = await next()
final.tools = [...final.tools, { name: 'smuggled', description: 'added after next()', parameters: {} }]
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
return final
})
await ctx.plugin(ModesService)
@@ -357,10 +357,10 @@ describe('the soft layer', () => {
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['exit_plan_mode', 'read', 'added-later'])
})
it('keeps run_code visible in plan mode under the registry Code Mode (transport, not capability)', async () => {
it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => {
// Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at
// assembly time (the code-mode.spec fake's shape).
class FakeRuntime extends CodeRuntime {
@@ -377,19 +377,16 @@ describe('the soft layer', () => {
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
// Code Mode's only wire tool survives the filter — without it the model
// would have NO tools at all, not even a path to the exit review.
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
// The SDK section is Code Mode's soft surface: it is re-rendered under
// the same visibility rule, so plan mode documents exactly the callable
// bindings — the allowlisted read and the exit — and never the denied write.
// The SDK documents the full binding set plus the exit — plan mode no
// longer prunes capabilities; its restraint is the section + the sandbox.
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).toContain('write(args:')
expect(sdk).toContain('exit_plan_mode(args:')
expect(sdk).not.toContain('write(args:')
})
it('filters native wire schemas by the allowlist under mode both, alongside the pruned SDK', async () => {
it('keeps native wire schemas and the SDK in step under mode both', async () => {
class FakeRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'fake'
@@ -404,12 +401,13 @@ describe('the soft layer', () => {
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
// ONE visibility rule covers both surfaces: the denied write is absent
// from the native wire schemas AND from the SDK declaration.
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code'])
// ONE visibility rule covers both surfaces: in plan the exit tool is
// present on the wire AND in the SDK, alongside the untouched toolset.
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['exit_plan_mode', 'read', 'run_code', 'write'])
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('read(args:')
expect(sdk).not.toContain('write(args:')
expect(sdk).toContain('write(args:')
expect(sdk).toContain('exit_plan_mode(args:')
})
it('default-mode Code Mode SDK is byte-identical to a no-dsh-mode deployment (exit binding hidden)', async () => {
@@ -462,66 +460,37 @@ describe('the hard layer', () => {
expect(defaulted.isError).toBe(false)
})
it('passes allowlisted calls and denies the rest with the plan-mode reason', async () => {
it('runs non-shell calls in plan mode untouched — restraint outside the shell is the section, not a gate', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const allowed = await execute(ctx, 'read', agent)
expect(allowed.isError).toBe(false)
const denied = await execute(ctx, 'write', agent)
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{
type: 'text',
text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
}])
const reading = await execute(ctx, 'read', agent)
expect(reading.isError).toBe(false)
const writing = await execute(ctx, 'write', agent)
expect(writing.isError).toBe(false)
})
it('denies with the generic reason in a custom mode', async () => {
const ctx = await setup({ modes: { review: { section: 's', tools: ['read'] } } })
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
const denied = await execute(ctx, 'write', agent)
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{ type: 'text', text: 'Error: tool "write" is not available in "review" mode' }])
})
it('passes run_code through the gate; bridged sub-calls are judged individually', async () => {
const ctx = await setup()
// Native mode here, so a stand-in run_code can register without clashing
// with the registry's own (Code Mode) instance; the gate exempts by name.
registerNamedTools(ctx, ['run_code', 'write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const wrapper = await execute(ctx, 'run_code', agent)
expect(wrapper.isError).toBe(false)
// A sub-dispatch re-enters execute() with the same agent — the capability
// is what the allowlist judges, exactly like a native call.
const sub = await execute(ctx, 'write', agent)
expect(sub.isError).toBe(true)
expect(sub.content).toEqual([{
type: 'text',
text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
}])
})
it('judges by the logged mode only — a pending intent does not gate', async () => {
it('judges by the logged mode only — a pending plan intent neither gates nor clamps', async () => {
const ctx = await setup()
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
ctx.modes.set(agent, PLAN_MODE)
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
})
it('treats a dropped folded definition as the default mode (no gate)', async () => {
it('treats a dropped folded definition as the default mode (no clamp, no guard)', async () => {
const ctx = await setup()
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
registerNamedTools(ctx, ['write'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'retired' })
const result = await execute(ctx, 'write', agent)
expect(result.isError).toBe(false)
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
})
})
@@ -597,9 +566,9 @@ describe('exit_plan_mode', () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step.' }])
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step.' }])
// Boundary-applied, not a direct append: the fold stays plan until the
// step's end, so the gate covers any remaining call of the SAME batch.
// step's end, so the plan policy covers any remaining call of the SAME batch.
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
expect(ctx.modes.get(agent)).toEqual({ current: PLAN_MODE, pending: DEFAULT_MODE })
boundary(ctx, agent.session, 'step/end')
@@ -609,22 +578,17 @@ describe('exit_plan_mode', () => {
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
})
it('an approved exit cannot smuggle a same-batch call past the gate', async () => {
it('an approved exit keeps the plan clamp until the boundary (same-batch policy holds)', async () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
registerNamedTools(ctx, ['write'])
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
const approved = await callExit(ctx, agent)
expect(approved.isError).toBe(false)
// The next call of the SAME assistant response (no boundary between):
// requested under the plan-shaped header, so the gate must still deny it.
const smuggled = await execute(ctx, 'write', agent)
expect(smuggled.isError).toBe(true)
expect(smuggled.content).toEqual([{
type: 'text',
text: 'Error: tool "write" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
}])
// A bash call of the SAME assistant response (no boundary between) was
// requested under the plan-shaped header the read-only clamp must
// still hold for it; the boundary flush is what widens the next step.
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
boundary(ctx, agent.session, 'step/end')
const next = await execute(ctx, 'write', agent)
expect(next.isError).toBe(false)
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
})
it('the exit flush narrates nothing — the tool result is the narration', async () => {
@@ -640,7 +604,7 @@ describe('exit_plan_mode', () => {
const { ctx, agent } = await setupWithReview({ selected: ['Approve'], custom: 'ship it small' })
const result = await callExit(ctx, agent)
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; the full toolset returns on your next step. User note: ship it small' }])
expect(result.content).toEqual([{ type: 'text', text: 'Plan approved — plan mode exited; carry out the plan starting with your next step. User note: ship it small' }])
})
it('keep planning returns the corrective error carrying the feedback verbatim', async () => {
@@ -789,7 +753,7 @@ describe('the access cap (bash/resolve-mode clamp)', () => {
})
it('is a min, not a replace: a knob narrower than the cap stays', async () => {
const ctx = await sandboxSetup('danger-full-access', { modes: { locked: { section: 's', tools: ['bash'], access: 'workspace-write' } } })
const ctx = await sandboxSetup('danger-full-access', { modes: { locked: { section: 's', access: 'workspace-write' } } })
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'locked' })
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
@@ -798,7 +762,7 @@ describe('the access cap (bash/resolve-mode clamp)', () => {
})
it('a mode without access leaves the resolution alone', async () => {
const ctx = await sandboxSetup('read-only', { modes: { review: { section: 's', tools: ['bash'] } } })
const ctx = await sandboxSetup('read-only', { modes: { review: { section: 's' } } })
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
setSandboxMode(agent.session, 'danger-full-access')
@@ -833,7 +797,7 @@ describe('the bash trio under an access cap', () => {
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['bash', 'bash_kill', 'bash_output', EXIT_PLAN_MODE, 'read'])
expect(assembly.tools.map(tool => tool.name).sort()).toEqual(['bash', 'bash_kill', 'bash_output', EXIT_PLAN_MODE, 'read', 'write'])
for (const name of TRIO) {
const result = await execute(ctx, name, agent)
expect(result.isError).toBe(false)
@@ -851,7 +815,7 @@ describe('the bash trio under an access cap', () => {
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{
type: 'text',
text: 'Error: tool "bash" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
text: 'Error: tool "bash" is not available in plan mode: its read-only sandbox cap needs a sandboxing bash executor, and none is mounted',
}])
})
@@ -867,7 +831,7 @@ describe('the bash trio under an access cap', () => {
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{
type: 'text',
text: 'Error: tool "bash_output" is not available in plan mode; continue planning and present your plan with exit_plan_mode when ready',
text: 'Error: tool "bash_output" is not available in plan mode: its read-only sandbox cap needs a sandboxing bash executor, and none is mounted',
}])
})
@@ -899,7 +863,7 @@ describe('the bash trio under an access cap', () => {
})
it('a mode without access exposes bash regardless of the executor (explicit deployment choice)', async () => {
const ctx = await setup({ modes: { shell: { section: 's', tools: ['bash'] } } })
const ctx = await setup({ modes: { shell: { section: 's' } } })
registerNamedTools(ctx, ['bash'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'shell' })