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:
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user