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

@@ -21,6 +21,7 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
| `get(id)` / `list()` | Task lookup. |
| `sandboxMode` | The capability fact for the tool layer: the default mode a SANDBOXING executor confines under (`undefined` in the base class — "this executor does not sandbox"). `dsh-tool-bash` reads it at registration to advertise the escalation fields only when the composition honors them. |
| `resolveMode(session)` | The per-call sandbox-mode resolution: the session's standing override falling back to the executor default, dispatched through the **`bash/resolve-mode` waterfall** so policy plugins narrow it per call (`dsh-mode`'s `access` cap is the shipped listener). Returns `undefined` — without consulting the waterfall — for a never-confining executor. `dsh-tool-bash` stamps the result onto each request; a freshly-approved escalation grant outranks it. |
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
@@ -32,6 +33,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner, sandboxMode) before execution; `owner` and `sandboxMode` are optional on the request and **required-but-nullable** on the resolved spec, so a forgotten one is a visible `undefined` rather than a silently-absent property. `sandboxMode` is the explicit per-call sandbox-policy input: an escalation grant a human just issued ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), which outranks) or the session's standing override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)); a sandboxing executor's `resolve()` stamps its configured default when the request carries none, and a non-sandboxing executor carries the field verbatim and confines nothing.
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), and THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode. Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
The seam also owns the per-session mode override vocabulary (the sandbox RFC § Per-session mode switching): the log-only `'bash/sandbox-mode'` session event, the pure fold `effectiveSandboxMode(events)` (last event wins; `undefined` means "apply the executor default"), THE write path `setSandboxMode(session, mode)` — the session log is the store, so an override survives restart by replay and two sessions can never see each other's mode — and THE read path `resolveMode(session)` above, which folds the override and runs the `bash/resolve-mode` waterfall around it. `SANDBOX_MODES` is the narrowest→widest ladder; the ordering is part of the contract (the escalation widening check and a mode's access clamp compare by index). Writers must respect turn-enclosure: the ACP bridge anchors an idle switch at the next turn rather than appending between turns. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId`, deliberately never unified with it (the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. A sandboxing executor additionally stamps `sandbox` result facts on results and settled tasks (`BashSandboxInfo`: the mode it executed under, the conservative `denied` classification, and — for confined modes — the backend's `enforcement` completeness); the mode/enforcement vocabulary is owned by the [`dsh-sandbox`](../../sandbox/sandbox/) seam, and the facts are documented in [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). See `src/types.ts` for the full contracts.
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).

View File

@@ -16,6 +16,8 @@
import { Context, Service } from 'cordis'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
import { effectiveSandboxMode } from './session-mode.ts'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
export { BashTaskId, OwnerToken } from './types.ts'
@@ -36,6 +38,24 @@ declare module 'cordis' {
interface Context {
bash: BashExecutor
}
interface Events {
/**
* Waterfall around {@link BashExecutor.resolveMode}'s base — the session's
* standing override falling back to the executor's configured default. A
* policy plugin narrows the resolution per call by clamping `await next()`
* (a session mode's `access` cap is the shipped example); returning
* without `next()` replaces the resolution outright. Dispatched only for
* a confining executor — a never-confining one resolves `undefined`
* without consulting listeners, so a listener always receives a real
* base mode from `next()`.
* @param session - the session the call belongs to (its log carries the
* override fold and any mode state a listener clamps by); `undefined`
* for a sessionless caller.
* @mode waterfall
*/
'bash/resolve-mode'(this: BashExecutor, session: Session | undefined, next: () => Promise<SandboxMode>): Promise<SandboxMode>
}
}
/**
@@ -89,6 +109,30 @@ export abstract class BashExecutor extends Service {
return undefined
}
/**
* Resolve the sandbox mode a call for `session` runs under: the session's
* standing override (the `bash/sandbox-mode` fold) falling back to this
* executor's configured default, dispatched through the `bash/resolve-mode`
* waterfall so policy plugins can narrow the base per call — read-time
* composition over independent folds, nothing written back to any store.
* Returns `undefined` — without consulting the waterfall — when this
* executor never confines ({@link sandboxMode} `undefined`): there is no
* mode to resolve and nothing would honor one. An escalation grant is not
* this method's business: the tool layer resolves grants separately and
* stamps them with higher precedence.
* @param session - the session whose override fold applies; `undefined`
* for a sessionless caller (the executor default alone seeds the
* waterfall).
* @returns the effective mode for a confining executor; `undefined` for
* one that never confines.
*/
async resolveMode(session: Session | undefined): Promise<SandboxMode | undefined> {
const fallback = this.sandboxMode
if (fallback === undefined) return undefined
const base = (session === undefined ? undefined : effectiveSandboxMode(session.events)) ?? fallback
return this.ctx.waterfall(this, 'bash/resolve-mode', session, () => Promise.resolve(base))
}
/**
* Resolve a caller's {@link BashExecRequest} into a fully-specified
* {@link BashExecSpec}, applying this implementation's config defaults and

View File

@@ -7,10 +7,13 @@
* other's state, and there is no external config store. The event is
* log-only (the `approval/*` precedent): the model learns the mode from the
* prompt section and the boundary notices in `@deepseek-ai/dsh-tool-bash`,
* never from the event itself. EXECUTION honors the fold in the tool layer —
* it stamps the effective mode onto each call's `BashExecRequest.sandboxMode`
* (weakest-precedence: an escalation grant for the call outranks it) — the
* executor itself stays a config-fixed default plus per-call overrides.
* never from the event itself. EXECUTION honors the fold through the seam's
* own resolution — `BashExecutor.resolveMode` computes `override ?? default`
* and dispatches it through the `bash/resolve-mode` waterfall so policy
* plugins can narrow it per call — and the tool layer stamps the resolved
* mode onto each call's `BashExecRequest.sandboxMode` (weakest-precedence: an
* escalation grant for the call outranks it); the executor itself stays a
* config-fixed default plus per-call overrides.
*
* @module dsh-bash/session-mode
*/
@@ -32,7 +35,12 @@ declare module '@deepseek-ai/dsh-session' {
}
}
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
/**
* Every {@link SandboxMode}, for option advertisement and runtime validation
* of untrusted mode strings. Ordered narrowest → widest — the ladder is part
* of the contract; consumers (the escalation widening check, a mode's access
* clamp) compare by index.
*/
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
/**

View File

@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
import { BashExecutor, BashTaskId, OwnerToken, setSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Minimal concrete executor: records calls, lets tests drive completions. */
class StubExecutor extends BashExecutor {
@@ -149,3 +151,50 @@ describe('BashExecutor service seam', () => {
expect(listener).not.toHaveBeenCalled()
})
})
/** A confining stub: the same executor with a configured default sandbox mode. */
class ConfiningStub extends StubExecutor {
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
}
describe('resolveMode (the bash/resolve-mode seam)', () => {
it('resolves undefined for a never-confining executor without consulting the waterfall', async () => {
const { ctx, bash } = await setup()
const listener = vi.fn()
ctx.on('bash/resolve-mode', listener)
expect(await bash.resolveMode(new Session(SessionId('rm-none')))).toBeUndefined()
expect(listener).not.toHaveBeenCalled()
})
it('resolves the executor default without a session and without an override', async () => {
const ctx = new Context()
await ctx.plugin(ConfiningStub)
expect(await ctx.bash.resolveMode(undefined)).toBe('workspace-write')
expect(await ctx.bash.resolveMode(new Session(SessionId('rm-default')))).toBe('workspace-write')
})
it('resolves the session override over the executor default', async () => {
const ctx = new Context()
await ctx.plugin(ConfiningStub)
const session = new Session(SessionId('rm-override'))
setSandboxMode(session, 'danger-full-access')
expect(await ctx.bash.resolveMode(session)).toBe('danger-full-access')
})
it('a waterfall listener narrows the base per call and sees the session', async () => {
const ctx = new Context()
await ctx.plugin(ConfiningStub)
const session = new Session(SessionId('rm-clamp'))
setSandboxMode(session, 'danger-full-access')
const seen: (Session | undefined)[] = []
ctx.on('bash/resolve-mode', async (sess, next) => {
seen.push(sess)
await next()
return 'read-only'
})
expect(await ctx.bash.resolveMode(session)).toBe('read-only')
expect(seen).toEqual([session])
})
})

View File

@@ -46,7 +46,10 @@
* Per-session mode switching (the sandbox RFC § Per-session mode switching): a session may carry a
* standing sandbox-mode override — the `bash/sandbox-mode` event fold from
* `@deepseek-ai/dsh-bash` — which this plugin makes real at EXECUTION: each
* call is stamped `escalation grant > session override > executor default`.
* call is stamped `escalation grant > ctx.bash.resolveMode()` (the seam's
* resolution: session override ?? executor default, run through the
* `bash/resolve-mode` waterfall so policy plugins — e.g. a session mode's
* `access` cap — narrow it per call).
* The prompt deliberately does NOT state the mode and no switch is narrated:
* the model learns the boundary from the denial marker (which names the mode
* it ran under) exactly when it matters, instead of preemptively refusing
@@ -67,7 +70,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
// stays optional at runtime, same pattern as dsh-tools' ask routing).
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
export const name = 'tool-bash'
@@ -480,19 +483,6 @@ export function apply(ctx: Context): void {
const defaultMode = ctx.bash.sandboxMode
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
/**
* The session's standing mode override for an ordinary (non-escalating)
* call: the `bash/sandbox-mode` fold of the calling agent's log, stamped
* onto the request so EXECUTION follows the same effective mode the prompt
* section states. Weakest precedence — an escalation grant (freshly
* approved for exactly this call) outranks it, and without either the
* executor's `resolve()` applies its configured default. Undefined for a
* non-sandboxing executor (nothing honors it) and for agent-less callers
* (no session to fold).
*/
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
/**
* Resolve a sandbox-escalation request through `ctx.approval` BEFORE
* anything executes. Returns the granted mode to stamp onto the bash
@@ -513,12 +503,14 @@ export function apply(ctx: Context): void {
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
}
// Strict widening is an EXECUTION check against the call's effective
// mode — session override ?? executor default, the same fold ordinary
// mode — the seam's resolveMode (session override ?? executor default,
// through the bash/resolve-mode waterfall), the same resolution ordinary
// calls are stamped with — deliberately not a schema constraint (the
// enum is the closed target vocabulary; the effective mode is per-call
// truth). A non-widening request fails closed here and never prompts a
// human.
const effectiveMode = (sessionOverride(exec) ?? defaultMode) as SandboxMode
// human. The cast is exact: escalationModes non-empty proved the executor
// confines, which is resolveMode's only undefined path.
const effectiveMode = (await ctx.bash.resolveMode(exec.agent?.session)) as SandboxMode
if (!(WIDER_MODES[effectiveMode] ?? []).includes(mode as SandboxMode)) {
throw new Error(`sandbox escalation to "${mode}" is not strictly wider than this call's current "${effectiveMode}" mode`)
}
@@ -586,11 +578,13 @@ export function apply(ctx: Context): void {
// An escalating call resolves approval BEFORE anything executes; every
// non-grant outcome throws its distinct error text and runs nothing.
// (validateBashArgs pinned the pairing, so the double narrow is exact.)
// An ordinary call carries the session's standing override instead —
// grant > session override > executor default (see sessionOverride).
// An ordinary call carries the seam's resolution instead — grant >
// ctx.bash.resolveMode() (session override ?? executor default, run
// through the bash/resolve-mode waterfall); undefined — stamp nothing —
// for a never-confining executor.
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: sessionOverride(exec)
: await ctx.bash.resolveMode(exec.agent?.session)
// Default the workdir to the calling agent's session cwd so each ACP
// session runs in its own workspace (see resolveWorkdir); an explicit
// model workdir still wins.

View File

@@ -1419,7 +1419,7 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
ctx.tools.execute({ callId: CallId(`call-mode-${++modeCall}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
it('stamps calls with grant > session override > nothing (executor default)', async () => {
it('stamps calls with grant > the seam resolution (override ?? executor default)', async () => {
const ctx = await setupModal('read-only', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const seen: (string | undefined)[] = []
@@ -1430,12 +1430,39 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
})
const { agent, session } = sessionAgent('sess-stamp-1')
const run = { command: 'true', description: 'stamp probe' }
await callAs(ctx, agent, run) // no override yet
await callAs(ctx, agent, run) // no override yet: the resolved default is stamped explicitly
setSandboxMode(session, 'workspace-write')
await callAs(ctx, agent, run) // standing override
await callAs(ctx, undefined, run) // agent-less caller: no session to fold
await callAs(ctx, undefined, run) // agent-less caller: no session to fold — still the resolved default
await callAs(ctx, agent, { ...run, sandbox_permissions: 'danger-full-access', justification: 'grant outranks override' })
expect(seen).toEqual([undefined, 'workspace-write', undefined, 'danger-full-access'])
expect(seen).toEqual(['read-only', 'workspace-write', 'read-only', 'danger-full-access'])
})
it('stamps the bash/resolve-mode waterfall result — a listener narrows both ordinary calls and the escalation baseline', async () => {
// A policy listener (dsh-mode's access cap is the shipped one) clamps the
// resolution to read-only. An ordinary call is stamped with the clamp, and
// the escalation strict-widening check runs against the CLAMPED baseline:
// under a workspace-write override, escalating TO workspace-write would be
// a non-widening no-op without the clamp — with it, the target is strictly
// wider than the call's effective read-only and the grant lands.
const ctx = await setupModal('workspace-write', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
ctx.on('bash/resolve-mode', async (_session, next) => {
await next()
return 'read-only'
})
const seen: (string | undefined)[] = []
const original = ctx.bash.resolve.bind(ctx.bash)
vi.spyOn(ctx.bash, 'resolve').mockImplementation((req) => {
seen.push(req.sandboxMode)
return original(req)
})
const { agent, session } = sessionAgent('sess-waterfall')
setSandboxMode(session, 'workspace-write')
await callAs(ctx, agent, { command: 'true', description: 'clamped probe' })
const escalated = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'wider than the clamped baseline' })
expect(escalated.isError).toBe(false)
expect(seen).toEqual(['read-only', 'workspace-write'])
})
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {

View File

@@ -84,6 +84,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
key: 'bash',
summary: 'Abstract bash execution service.',
methods: [
'async resolveMode(session: Session | undefined): Promise<SandboxMode | undefined>',
'abstract resolve(request: BashExecRequest): BashExecSpec',
'abstract run(spec: BashExecSpec): Promise<BashRunResult>',
'abstract start(spec: BashExecSpec): BashTask',
@@ -318,6 +319,12 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'approval/request\'(this: ApprovalService, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>',
summary: 'Waterfall asking the composed answerers to decide one approval request.',
},
{
name: 'bash/resolve-mode',
mode: 'waterfall',
signature: '\'bash/resolve-mode\'(this: BashExecutor, session: Session | undefined, next: () => Promise<SandboxMode>): Promise<SandboxMode>',
summary: 'Waterfall around BashExecutor.resolveMode\'s base — the session\'s standing override falling back to the executor\'s configured default.',
},
{
name: 'fs/edit-intent',
mode: 'waterfall',

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"
}