feat(mode): the access cap — plan mode composes with the sandbox instead of banning bash

A ModeDefinition may declare access: the widest sandbox access shell
commands run under while the mode holds, on the SANDBOX_MODES ladder.
The bash seam gains the resolution point to hang it on: BashExecutor.
resolveMode(session) folds override ?? default and dispatches the new
bash/resolve-mode waterfall; dsh-tool-bash consults it at both the
stamping site and the escalation baseline; dsh-mode's clamp listener
takes the ladder minimum per call. Two independent log folds compose at
read time — the mode never writes the sandbox knob, so the two switch
in any order and the knob re-emerges intact on exit.

The built-in plan definition ships access: read-only with the bash trio
allowlisted CONDITIONALLY: both policy layers admit bash/bash_output/
bash_kill only while a confining executor is mounted (an unconfinable
shell cannot honor the cap), and a bash call carrying sandbox_permissions
under a cap is denied at the gate — no widening mid-mode; the widened
step belongs in the plan.

examples/plan-acp-agent swaps bash-local for sandbox-local +
bash-sandbox (workspace-write default, clamped read-only inside plan)
plus the approval seam; the re-recorded plan-mode arc runs a real cat
inside plan under the clamped sandbox, and modes-advertise now pins the
sandbox-mode and approval config options. RFC amended to the landed
shape (access cap section, orthogonality FAQ, deferred item resolved
into effects self-declaration).
This commit is contained in:
kingwl
2026-07-12 22:51:09 +08:00
parent 88db403d9f
commit 99650a201b
31 changed files with 1844 additions and 1237 deletions

View File

@@ -14,6 +14,14 @@ The `default` mode is the absence of policy: no section, no filtering, no gate.
**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, 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.
## `ctx.modes`
`list()` returns the selectable vocabulary (`default` first, then the configured definitions); `get(agent)` returns the folded mode (a folded name the config no longer defines reads as `default`) plus any pending intent; `set(agent, mode)` validates against `list()` (loud on unknown; `default` is always a valid target) and records a pending intent — every session event is turn-enclosed and an idle agent has no open turn, so the service flushes the intent at the next `turn/start`/`step/end` and, when the flushed mode differs from what the last logged request header told the model, appends one coalesced `context/message` notice in the same frame. A net-zero flip sequence appends nothing.
@@ -34,9 +42,10 @@ 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, exit_plan_mode]
tools: [read, todo_write, web_search, web_fetch, ask_user_question, structured_output, bash, bash_output, bash_kill, exit_plan_mode]
access: read-only
```
Definitions are validated at load (`resolveConfig`): the built-in `plan` (read-only allowlist plus the ask/report channels `ask_user_question`/`structured_output`, `bash`/`subagent` excluded) merges unless overridden, `default` is rejected as a key, 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 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.
RFC: [plan mode](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md).

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -32,6 +33,7 @@
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -3,8 +3,11 @@
* 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);
* 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.
* 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 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
@@ -28,6 +31,11 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool, renderToolsSdk, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import type { PreToolDecision } from '@deepseek-ai/dsh-tools'
// Value import (not type-only): the access-cap vocabulary IS the bash seam's
// sandbox-mode ladder, and the import also merges the `bash/resolve-mode`
// event and `ctx.bash` declarations the clamp listener and the bash-family
// gating read. The seam itself stays optional at runtime (`ctx.get('bash')`).
import { SANDBOX_MODES } from '@deepseek-ai/dsh-bash'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
@@ -78,14 +86,25 @@ 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
* and the allowlist of tool names that stay visible and executable.
* 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.
*/
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
* `bash/resolve-mode` clamp), not a switch: the session's own sandbox knob
* keeps its setting and re-emerges intact when the mode ends. Omitted, the
* mode leaves the resolution alone. A mode with `access` set exposes the
* bash tools only while a confining executor is mounted (an unconfinable
* shell cannot honor the cap) and denies sandbox escalation outright.
*/
access?: (typeof SANDBOX_MODES)[number]
}
/**
@@ -107,7 +126,11 @@ 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. When a decision or a missing detail blocks the plan, ask the '
+ '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 '
+ '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 '
@@ -115,12 +138,22 @@ const PLAN_SECTION
+ 'its review fails, ask the user to switch the session out of plan mode instead '
+ 'of retrying denied tools.'
/**
* 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.
*/
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.
const PLAN_TOOLS = ['read', 'todo_write', 'web_search', 'web_fetch', 'ask_user_question', 'structured_output', EXIT_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'
@@ -143,6 +176,11 @@ function firstHeading(plan: string): string | undefined {
return undefined
}
/** Whether a bash call's parsed arguments carry the escalation field (`sandbox_permissions`). */
function hasEscalationArgs(args: unknown): boolean {
return typeof args === 'object' && args !== null && (args as { sandbox_permissions?: unknown }).sandbox_permissions !== undefined
}
/**
* Validate the config and merge the built-in `plan` definition (explicit
* resolve step — the `dsh-bash` request/spec template). Fail-loud: a
@@ -153,7 +191,7 @@ function firstHeading(plan: string): string | undefined {
*/
export function resolveConfig(config: ModeConfig): ResolvedModes {
const definitions = new Map<string, ModeDefinition>()
definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS] })
definitions.set(PLAN_MODE, { section: PLAN_SECTION, tools: [...PLAN_TOOLS], 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`)
@@ -164,7 +202,14 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
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`)
}
definitions.set(name, { section: definition.section, tools: [...definition.tools] })
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(', ')}`)
}
definitions.set(name, {
section: definition.section,
tools: [...definition.tools],
...definition.access !== undefined ? { access: definition.access } : {},
})
}
return { definitions }
}
@@ -274,8 +319,15 @@ export class ModesService extends Service {
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)
allowed.has(name)
&& (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
@@ -314,13 +366,44 @@ export class ModesService extends Service {
// 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()
if (active.definition.tools.includes(exec.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 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 })
})
// The access cap made real: clamp the bash seam's per-call resolution to
// the active mode's declared access. Read-time composition of two
// independent folds — the sandbox knob's and the mode's — neither writes
// the other, so the knob re-emerges intact when the mode ends and a crash
// between them can strand nothing. SANDBOX_MODES is the narrowest-first
// ladder; the clamp is an index min.
ctx.on('bash/resolve-mode', async (session, next) => {
const base = await next()
if (session === undefined) return base
const access = this.activeDefinition(session)?.definition.access
if (access === undefined) return base
return SANDBOX_MODES.indexOf(base) <= SANDBOX_MODES.indexOf(access) ? base : access
})
ctx.tools.register(defineTool({
name: EXIT_PLAN_MODE,
description: EXIT_DESCRIPTION,

View File

@@ -8,6 +8,8 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { BashExecutor, setSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import ModesService, { DEFAULT_MODE, EXIT_PLAN_MODE, PLAN_MODE, foldMode, resolveConfig } from '../src/index.ts'
import type { ModeConfig } from '../src/index.ts'
@@ -76,8 +78,9 @@ describe('resolveConfig', () => {
it('merges the built-in plan definition with the read-only allowlist', () => {
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', EXIT_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?.section).toContain('plan mode')
expect(plan?.access).toBe('read-only')
})
it('lets config override plan and add further modes', () => {
@@ -102,6 +105,13 @@ describe('resolveConfig', () => {
expect(() => resolveConfig({ modes: { bad: { section: '', tools: [7] } as unknown as { section: string; tools: string[] } } }))
.toThrow('needs a `tools` array')
})
it('validates access against the sandbox-mode ladder', () => {
expect(() => resolveConfig({ modes: { locked: { section: 's', tools: [], 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' })
})
})
describe('foldMode', () => {
@@ -715,3 +725,187 @@ describe('exit_plan_mode', () => {
})
})
})
/**
* A minimal confining executor for the access-cap tests: only `sandboxMode`
* (the capability fact both policy layers and `resolveMode` read) matters;
* the task API is never exercised here.
*/
class FakeSandboxExecutor extends BashExecutor {
constructor(ctx: Context, private readonly config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access' } = {}) {
super(ctx)
}
override get sandboxMode() {
return this.config.mode
}
resolve(request: BashExecRequest): BashExecSpec {
return { command: request.command, workdir: '/w', timeoutMs: 1000, owner: request.owner, sandboxMode: request.sandboxMode }
}
run(_spec: BashExecSpec): Promise<BashRunResult> {
return Promise.resolve({
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
})
}
start(_spec: BashExecSpec): BashTask { throw new Error('unused in access-cap tests') }
get(): BashTask | undefined { return undefined }
ownerOf(): OwnerToken | undefined { return undefined }
list(): BashTask[] { return [] }
readOutput(): BashTaskRead { throw new Error('unused in access-cap tests') }
kill(): boolean { return false }
}
describe('the access cap (bash/resolve-mode clamp)', () => {
async function sandboxSetup(mode: 'read-only' | 'workspace-write' | 'danger-full-access' | undefined, config?: ModeConfig): Promise<Context> {
const ctx = await setup(config)
await ctx.plugin(FakeSandboxExecutor, mode !== undefined ? { mode } : {})
return ctx
}
it('clamps the plan-mode resolution to read-only over a wider knob and default', async () => {
const ctx = await sandboxSetup('workspace-write')
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
setSandboxMode(agent.session, 'danger-full-access')
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
})
it('leaves the default-mode resolution alone (knob ?? executor default)', async () => {
const ctx = await sandboxSetup('workspace-write')
const agent = agentWithSession()
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
setSandboxMode(agent.session, 'danger-full-access')
expect(await ctx.bash.resolveMode(agent.session)).toBe('danger-full-access')
})
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 agent = agentWithSession()
agent.session.append('mode/set', { mode: 'locked' })
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
setSandboxMode(agent.session, 'read-only')
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
})
it('a mode without access leaves the resolution alone', async () => {
const ctx = await sandboxSetup('read-only', { modes: { review: { section: 's', tools: ['bash'] } } })
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'review' })
setSandboxMode(agent.session, 'danger-full-access')
expect(await ctx.bash.resolveMode(agent.session)).toBe('danger-full-access')
})
it('a sessionless resolution passes through the clamp untouched', async () => {
const ctx = await sandboxSetup('workspace-write')
expect(await ctx.bash.resolveMode(undefined)).toBe('workspace-write')
})
it('orthogonality: the knob set during plan is capped, then re-emerges intact on exit', async () => {
const ctx = await sandboxSetup('workspace-write')
const agent = agentWithSession()
// Enter plan, then flip the knob mid-mode: the cap holds it down…
agent.session.append('mode/set', { mode: PLAN_MODE })
setSandboxMode(agent.session, 'danger-full-access')
expect(await ctx.bash.resolveMode(agent.session)).toBe('read-only')
// …and leaving plan uncovers the standing knob, unwritten by the cap.
agent.session.append('mode/set', { mode: DEFAULT_MODE })
expect(await ctx.bash.resolveMode(agent.session)).toBe('danger-full-access')
})
})
describe('the bash trio under an access cap', () => {
const TRIO = ['bash', 'bash_output', 'bash_kill']
it('exposes and admits the trio in plan mode under a confining executor', async () => {
const ctx = await setup()
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
registerNamedTools(ctx, ['read', 'write', ...TRIO])
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'])
for (const name of TRIO) {
const result = await execute(ctx, name, agent)
expect(result.isError).toBe(false)
}
})
it('hides and denies the trio in plan mode without any executor', async () => {
const ctx = await setup()
registerNamedTools(ctx, ['read', ...TRIO])
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'])
const denied = await execute(ctx, 'bash', agent)
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',
}])
})
it('hides and denies the trio in plan mode under a never-confining executor', async () => {
const ctx = await setup()
await ctx.plugin(FakeSandboxExecutor, {})
registerNamedTools(ctx, ['read', ...TRIO])
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'])
const denied = await execute(ctx, 'bash_output', agent)
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',
}])
})
it('denies a bash call carrying sandbox_permissions under the cap (no widening mid-mode)', async () => {
const ctx = await setup()
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
registerNamedTools(ctx, TRIO)
const agent = agentWithSession()
agent.session.append('mode/set', { mode: PLAN_MODE })
const denied = await ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name: 'bash',
arguments: { command: 'rm -rf x', description: 'd', sandbox_permissions: 'workspace-write', justification: 'j' },
agent,
})
expect(denied.isError).toBe(true)
expect(denied.content).toEqual([{
type: 'text',
text: 'Error: sandbox escalation is not available in plan mode — the sandbox stays read-only while it is in force; put the wider-access step in the plan for after approval',
}])
// The same command WITHOUT the escalation fields passes the gate.
const plain = await ctx.tools.execute({
callId: CallId(`call-${++callCounter}`),
name: 'bash',
arguments: { command: 'ls', description: 'd' },
agent,
})
expect(plain.isError).toBe(false)
})
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'] } } })
registerNamedTools(ctx, ['bash'])
const agent = agentWithSession()
agent.session.append('mode/set', { mode: 'shell' })
const assembly = await ctx.systemPrompt.assemble({ agent })
expect(assembly.tools.map(tool => tool.name)).toEqual(['bash'])
const result = await execute(ctx, 'bash', agent)
expect(result.isError).toBe(false)
})
})

View File

@@ -26,6 +26,9 @@
{
"path": "../../core/system-prompt"
},
{
"path": "../../bash/bash"
},
{
"path": "../../ui/user-interaction"
}