refactor(mode): modes are collaboration states — drop the access cap; enforcement axes stay independent
Review follow-up (tianyicui): plan mode and the sandbox are orthogonal
AXES, not just orthogonal state — entering plan must not change what the
sandbox enforces, matching Codex's separation of Plan/Default
collaboration presets from sandbox and approval settings.
ModeDefinition.access, the bash/resolve-mode clamp, and both cap-derived
guards are removed; a ModeDefinition is exactly { section }, and a mode
now carries only its guidance section plus the exit_plan_mode review.
The bash seam's resolveMode + waterfall go with their only listener:
dsh-bash and dsh-tool-bash revert to master byte-for-byte, and the
dsh-mode → dsh-bash dependency edge is gone. A deployment that wants
kernel-enforced read-only planning pairs the mode picker with the
independent sandbox-mode option, in either order.
The RFC archives this as the second removed enforcement shape (after
the interim allowlist) with the same restart trigger — effects
self-declaration; the orthogonality FAQ now answers with the two-axis
rule. The plan example demonstrates the axes side by side, and the
re-recorded fixtures pin the guidance-only section.
This commit is contained in:
@@ -20,7 +20,6 @@ The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool su
|
||||
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
|
||||
| `start(spec)` | Background execution. Returns a task-free `BashProcess` handle immediately; **no timeout applies**. The caller may adapt it into `ctx.tasks`. |
|
||||
| `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. |
|
||||
| `BashProcess.readOutput()` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. |
|
||||
| `BashProcess.kill()` | Kill the process group. Returns `false` when it already finished. |
|
||||
|
||||
@@ -30,7 +29,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
`BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxMode?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxMode) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxMode` is optional on the request and required-but-nullable on the resolved spec: it carries an approved one-shot escalation or the session's standing override; a sandboxing executor stamps its configured default when absent, while a non-sandboxing executor carries the field and confines nothing.
|
||||
|
||||
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, the `setSandboxMode(session, mode)` write path, 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). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
The seam also owns the per-session mode override vocabulary: the log-only `'bash/sandbox-mode'` session event, the pure `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path. `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment RFC](../../../docs/rfc/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
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, BashProcess, BashRunResult } from './types.ts'
|
||||
|
||||
export { DSH_ENV_PREFIX } from './types.ts'
|
||||
@@ -30,24 +28,6 @@ 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>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,30 +60,6 @@ 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply implementation-owned defaults and caps to a request before execution.
|
||||
* @param request - the caller's request; omitted fields get this
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* Per-session sandbox-mode override stored as log-only events. Folding the log
|
||||
* isolates sessions and survives replay; execution reads it through
|
||||
* `BashExecutor.resolveMode` (`override ?? default`, dispatched through the
|
||||
* `bash/resolve-mode` waterfall so policy plugins narrow it per call), and the
|
||||
* tool stamps the resolution onto each call unless an approved one-shot
|
||||
* escalation outranks it. The model receives neither the event nor a
|
||||
* standing-mode notice; denial results name the effective mode.
|
||||
* isolates sessions and survives replay; the tool stamps the override onto
|
||||
* each call unless an approved one-shot escalation outranks it, and the
|
||||
* executor default applies when neither exists. The model receives neither the
|
||||
* event nor a standing-mode notice; denial results name the effective mode.
|
||||
* @module dsh-bash/session-mode
|
||||
*/
|
||||
|
||||
@@ -23,12 +21,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
|
||||
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BashExecutor, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
/**
|
||||
* Minimal concrete executor: canned foreground results, a hand-built process
|
||||
@@ -84,51 +82,3 @@ describe('BashExecutor service seam', () => {
|
||||
await expect(ctx.plugin(SecondExecutor)).rejects.toThrow(/service "bash" has been registered/)
|
||||
})
|
||||
})
|
||||
|
||||
/** 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 = new Context()
|
||||
await ctx.plugin(StubExecutor)
|
||||
const listener = vi.fn()
|
||||
ctx.on('bash/resolve-mode', listener)
|
||||
expect(await ctx.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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
* register process handles with `ctx.tasks`; their work uses task cancellation
|
||||
* rather than the tool-call signal after an id is returned.
|
||||
*
|
||||
* Each call is stamped `escalation grant > ctx.bash.resolveMode()` — the
|
||||
* seam's resolution (session override ?? executor default) run through the
|
||||
* `bash/resolve-mode` waterfall, where 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 exactly when it matters.
|
||||
*
|
||||
* TODO(permissions): deployment policy belongs in `tools/pre-execute` and
|
||||
* sandboxing executors; see docs/architecture.md § Extending The Harness.
|
||||
* @module @deepseek-ai/dsh-tool-bash
|
||||
@@ -27,7 +20,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_ENV_PREFIX, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
|
||||
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
|
||||
import { processOutcome } from './background.ts'
|
||||
@@ -350,14 +343,14 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
const defaultMode = ctx.bash.sandboxMode
|
||||
const escalationModes: readonly SandboxMode[] = defaultMode === undefined ? [] : ESCALATION_TARGETS
|
||||
|
||||
const sessionOverride = (exec: ToolExecution): SandboxMode | undefined =>
|
||||
defaultMode === undefined || exec.agent === undefined ? undefined : effectiveSandboxMode(exec.agent.session.events)
|
||||
|
||||
const approveEscalation = async (mode: string, justification: string, exec: ToolExecution): Promise<SandboxMode> => {
|
||||
if (escalationModes.length === 0) {
|
||||
throw new Error('sandbox_permissions is not available in this composition (no sandboxing executor to escalate)')
|
||||
}
|
||||
// Strict widening runs against the seam's resolution — the same value
|
||||
// ordinary calls are stamped with. The cast is exact: escalationModes
|
||||
// non-empty proved the executor confines, resolveMode's only undefined path.
|
||||
const effectiveMode = (await ctx.bash.resolveMode(exec.agent?.session)) as SandboxMode
|
||||
const effectiveMode = (sessionOverride(exec) ?? defaultMode) 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`)
|
||||
}
|
||||
@@ -425,7 +418,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
// Description is display metadata; workdir defaults to the caller's session.
|
||||
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
|
||||
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
|
||||
: await ctx.bash.resolveMode(exec.agent?.session)
|
||||
: sessionOverride(exec)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const dshEnv = bashEnv.collect(exec)
|
||||
const request = {
|
||||
|
||||
@@ -613,25 +613,6 @@ describe('sandbox escalation through the generic task producer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the bash/resolve-mode waterfall at the tool layer', () => {
|
||||
it('stamps the waterfall result — a listener narrows ordinary calls and the escalation baseline alike', async () => {
|
||||
const { ctx, bash } = await setupSandboxed(true)
|
||||
ctx.on('bash/resolve-mode', async (_session, next) => {
|
||||
await next()
|
||||
return 'read-only'
|
||||
})
|
||||
const agent = sandboxAgent('workspace-write')
|
||||
await call(ctx, 'bash', { command: 'true', description: 'clamped ordinary' }, agent)
|
||||
// Escalating TO workspace-write is strictly wider than the CLAMPED
|
||||
// read-only baseline — without the clamp it would be a non-widening no-op
|
||||
// against the standing override — and the freshly-approved grant outranks
|
||||
// the clamp for exactly that call.
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await call(ctx, 'bash', { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'wider than the clamped baseline' }, agent)
|
||||
expect(bash.modes).toEqual(['read-only', 'workspace-write'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderProcessRead', () => {
|
||||
const base: BashProcessRead = { delta: 'out\n', lossy: false }
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ 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): BashProcess',
|
||||
@@ -377,12 +376,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
signature: '\'approval/request\'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>',
|
||||
summary: 'Ask composed answerers for one decision.',
|
||||
},
|
||||
{
|
||||
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',
|
||||
|
||||
@@ -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 (section text, the `access` cap), not swappable implementations.
|
||||
Session modes: named, logged, per-agent collaboration 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 part is a config value (the section text), not a swappable implementation.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `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` |
|
||||
| `mode/` | `mode/set` vocabulary + fold, the `ctx.modes` service (list/get/set with the turn-boundary flush), the `mode:policy` guidance section, 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 front door](../ui/stdio) 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,20 +1,18 @@
|
||||
# @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 stance, produces a reviewable plan, and crosses back into full authority through an explicit review.
|
||||
Session modes: named, logged, per-agent COLLABORATION states. **Plan mode** is the first shipped definition — the agent explores and designs under a planning stance, produces a reviewable plan, and crosses back through an explicit review. Modes are one axis; enforcement knobs (the sandbox mode, the approval policy) are others — they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings.
|
||||
|
||||
## 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 cap. 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 extra tool. An agent that never sees a `mode/set` behaves byte-identically to a deployment that never loads this plugin.
|
||||
|
||||
## What a mode enforces
|
||||
## What a mode carries
|
||||
|
||||
**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`).
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
**Deliberately absent: enforcement.** A mode never gates execution, filters the toolset, or touches the sandbox/approval knobs — those are independent axes the user switches separately (a deployment that wants a hard read-only floor while planning flips the sandbox-mode option beside the mode picker, in either order; neither disturbs the other). A per-mode tool allow/deny list is likewise out: which tools a mode admits is an effects question — a per-tool read-only/mutating classification the harness does not yet have — parked until tool definitions declare their effects (the plan-mode RFC's deferred item). The config vocabulary is exactly `{ section }`, and an unknown key (a `tools` list or an `access` cap included) fails loud at load.
|
||||
|
||||
## `ctx.modes`
|
||||
|
||||
@@ -24,7 +22,7 @@ The `default` mode is the absence of policy: no section, no cap. An agent that n
|
||||
|
||||
## `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 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.
|
||||
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 surface keeps holding for any remaining call of the same assistant response) and the next step reflects the exit; 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
|
||||
|
||||
@@ -36,23 +34,22 @@ The model-facing exit tool. Its single required argument is the plan text — a
|
||||
plan:
|
||||
section: |
|
||||
You are in plan mode: ...
|
||||
access: read-only
|
||||
```
|
||||
|
||||
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.
|
||||
Definitions are validated at load (`resolveConfig`): the built-in `plan` (the shipped guidance section) merges unless overridden, `default` is rejected as a key, and any other key — a `tools` list or an `access` cap included — fails loud. An unknown mode name fails loudly at `set()` time.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
**What the model sees**: In the default mode, nothing — assemblies are byte-identical to a deployment without this plugin (the always-registered `exit_plan_mode` tool is filtered from the wire and the Code Mode SDK). In a non-default mode, the mode's `section` renders as the `mode:policy` section (order 50) and, in plan mode, the `exit_plan_mode` tool joins the toolset; under an unhonorable `access` cap the `bash` tool disappears. A mode flip mid-session appends one coalesced `context/message` notice when the last header disagrees.
|
||||
**What the model sees**: In the default mode, nothing — assemblies are byte-identical to a deployment without this plugin (the always-registered `exit_plan_mode` tool is filtered from the wire and the Code Mode SDK). In a non-default mode, the mode's `section` renders as the `mode:policy` section (order 50) and, in plan mode, the `exit_plan_mode` tool joins the toolset — nothing else changes. A mode flip mid-session appends one coalesced `context/message` notice when the last header disagrees.
|
||||
|
||||
**Token effect**: Zero in the default mode. In plan mode, the section text plus one tool schema per request; every mode transition changes the logged header and therefore resets the provider prefix cache.
|
||||
|
||||
#### Plan-mode policy section
|
||||
|
||||
```markdown
|
||||
You are in plan mode: a read-only planning state. Explore, analyze, and design; 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 pressing on.
|
||||
You are in plan mode: a planning state. Explore, analyze, and design; reading files and running read-only commands is fine, but hold off on changes — edits and other side effects belong in the plan and run after its approval, not in this mode. 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 pressing on.
|
||||
```
|
||||
|
||||
### Exit review
|
||||
@@ -63,8 +60,7 @@ You are in plan mode: a read-only planning state. Explore, analyze, and design;
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Non-shell restraint is guidance-only** — until effects self-declaration lands on tool definitions, a non-default mode restrains tools other than `bash` by its section text alone; the [plan-mode RFC](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md) archives the removed interim allowlist and its restart trigger.
|
||||
- **The cap guard names `bash` rather than deriving it** — the same effects item generalizes it.
|
||||
- **A mode restrains by guidance only** — nothing gates execution while a mode holds; a user who wants a hard floor pairs the mode with the independent sandbox/approval knobs. The [plan-mode RFC](../../../docs/rfc/implemented/feature/2026-07-07-plan-mode.md) archives the two removed enforcement shapes (the interim allowlist, the `access` sandbox cap) and their restart trigger (effects self-declaration on tool definitions).
|
||||
- **A pending flip set while idle dies with the process** — the UI re-applies; the idle-record primitive is the escape hatch if this bites.
|
||||
- **Subagent mode inheritance is deferred** — a fork child inherits via the seeded prefix; a spawn child starts default unless its creator seeds `AgentOptions.mode`.
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"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",
|
||||
@@ -33,7 +32,6 @@
|
||||
"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:^",
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/**
|
||||
* Session modes: named, logged, per-agent policy states, with **plan mode** as
|
||||
* 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.
|
||||
* Session modes: named, logged, per-agent COLLABORATION states, with **plan
|
||||
* mode** as the first shipped definition. A mode is a guidance section the
|
||||
* model sees while it is in force plus, for plan, the user-reviewed
|
||||
* `exit_plan_mode` crossing — deliberately nothing more. Modes are one axis
|
||||
* and enforcement knobs (the sandbox mode, the approval policy) are others;
|
||||
* they never read or write each other, exactly as Codex separates its
|
||||
* Plan/Default collaboration presets from its sandbox and approval settings.
|
||||
* There is likewise NO per-mode 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). 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 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.
|
||||
* The default mode is the absence of policy: no section, no extra tool. 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
|
||||
@@ -38,12 +36,6 @@ import { Context, Service } from 'cordis'
|
||||
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'
|
||||
|
||||
@@ -94,24 +86,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
|
||||
* 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.
|
||||
* One mode's deployment-configured policy: the guidance section the model
|
||||
* sees. Deliberately nothing else — enforcement knobs (sandbox mode, approval
|
||||
* policy) are separate axes a mode never touches, and a tool allow/deny list
|
||||
* 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
|
||||
/**
|
||||
* 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]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,13 +113,10 @@ export interface ResolvedModes {
|
||||
}
|
||||
|
||||
const PLAN_SECTION
|
||||
= 'You are in plan mode: a read-only planning state. Explore, analyze, and design; '
|
||||
+ '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 '
|
||||
= 'You are in plan mode: a planning state. Explore, analyze, and design; reading '
|
||||
+ 'files and running read-only commands is fine, but hold off on changes — edits '
|
||||
+ 'and other side effects belong in the plan and run after its approval, not in '
|
||||
+ 'this mode. 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 '
|
||||
@@ -145,14 +124,6 @@ const PLAN_SECTION
|
||||
+ 'its review fails, ask the user to switch the session out of plan mode instead '
|
||||
+ 'of pressing on.'
|
||||
|
||||
/**
|
||||
* The bash tools an `access` cap conditions on a confining executor: today
|
||||
* exactly the starter, `bash` — the generic task controls (`task_output`/
|
||||
* `task_kill`, from dsh-tool-tasks) span every task kind and only observe or
|
||||
* stop work, so they stay visible regardless of the cap.
|
||||
*/
|
||||
const BASH_FAMILY = ['bash']
|
||||
|
||||
/** The review question's approve option label — the answer item is matched by it. */
|
||||
const APPROVE_LABEL = 'Approve'
|
||||
|
||||
@@ -174,11 +145,6 @@ 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
|
||||
@@ -189,7 +155,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, access: 'read-only' })
|
||||
definitions.set(PLAN_MODE, { section: PLAN_SECTION })
|
||||
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`)
|
||||
@@ -197,20 +163,14 @@ export function resolveConfig(config: ModeConfig): ResolvedModes {
|
||||
if (typeof definition.section !== 'string') {
|
||||
throw new Error(`ModeConfig: mode "${name}" needs a string \`section\``)
|
||||
}
|
||||
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')
|
||||
// definition vocabulary is exactly { section }: a tool allow/deny list
|
||||
// and enforcement knobs are deliberately NOT part of it (module doc).
|
||||
const unknown = Object.keys(definition).filter(key => key !== 'section')
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`ModeConfig: mode "${name}" has unknown key(s) ${unknown.join(', ')} — a definition is { section, access? }`)
|
||||
throw new Error(`ModeConfig: mode "${name}" has unknown key(s) ${unknown.join(', ')} — a definition is { section }`)
|
||||
}
|
||||
definitions.set(name, {
|
||||
section: definition.section,
|
||||
...definition.access !== undefined ? { access: definition.access } : {},
|
||||
})
|
||||
definitions.set(name, { section: definition.section })
|
||||
}
|
||||
return { definitions }
|
||||
}
|
||||
@@ -249,9 +209,9 @@ function modeAtLastHeader(events: readonly SessionEvent[]): string | undefined {
|
||||
|
||||
/**
|
||||
* `ctx.modes`: the session-mode service. Owns the `mode/set` vocabulary, the
|
||||
* pending-intent flush, the boundary narration, and both policy layers (the
|
||||
* assemble filter + `mode:policy` section, and the `tools/pre-execute` gate).
|
||||
* UIs read mode flips off `session/event`; there is no live mirror.
|
||||
* pending-intent flush, the boundary narration, the `mode:policy` section,
|
||||
* and the exit tool's visibility rule. UIs read mode flips off
|
||||
* `session/event`; there is no live mirror.
|
||||
*/
|
||||
export class ModesService extends Service {
|
||||
static inject = ['tools', 'systemPrompt']
|
||||
@@ -316,38 +276,20 @@ 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. 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).
|
||||
// too. It hides exactly ONE thing: the always-registered exit tool, wherever
|
||||
// the folded mode is not plan — which keeps a default-mode assembly
|
||||
// byte-identical to a no-dsh-mode deployment (whose registry never saw the
|
||||
// tool) and keeps custom modes from advertising a binding that only errors.
|
||||
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
|
||||
const result = await next()
|
||||
const agent = context.agent
|
||||
if (agent === undefined) return result
|
||||
const active = this.activeDefinition(agent.session)
|
||||
if (active === undefined) {
|
||||
result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE)
|
||||
// The default mode hides exactly one thing on BOTH soft surfaces: the
|
||||
// exit tool (registered always, callable only in plan). Without the
|
||||
// SDK re-render a Code Mode deployment would still advertise an
|
||||
// exit_plan_mode binding that can only error — and the default-mode
|
||||
// assembly would no longer be byte-identical to a no-dsh-mode
|
||||
// deployment, whose registry never saw the tool at all.
|
||||
rerenderSdk(result, name => name !== EXIT_PLAN_MODE)
|
||||
return result
|
||||
}
|
||||
// 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 =>
|
||||
(name !== EXIT_PLAN_MODE || active.name === PLAN_MODE)
|
||||
&& (bashUsable || !BASH_FAMILY.includes(name))
|
||||
result.tools = result.tools.filter(tool => visible(tool.name))
|
||||
if (this.activeDefinition(agent.session)?.name === PLAN_MODE) return result
|
||||
result.tools = result.tools.filter(tool => tool.name !== EXIT_PLAN_MODE)
|
||||
// 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.
|
||||
rerenderSdk(result, visible)
|
||||
rerenderSdk(result, name => name !== EXIT_PLAN_MODE)
|
||||
return result
|
||||
}, { prepend: true })
|
||||
|
||||
@@ -366,52 +308,6 @@ 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)
|
||||
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`,
|
||||
})
|
||||
}
|
||||
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
|
||||
// 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,
|
||||
@@ -453,11 +349,10 @@ 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 — 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.
|
||||
// the plan surface (the section, the exit tool's visibility) keeps
|
||||
// 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 reflects
|
||||
// the exit; 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; carry out the plan starting with your next step.${note}` }]
|
||||
|
||||
@@ -7,8 +7,6 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId, agentEvents, 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, BashProcess, BashRunResult } 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'
|
||||
|
||||
@@ -84,20 +82,20 @@ function execute(ctx: Context, name: string, agent?: Agent) {
|
||||
}
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
it('merges the built-in plan definition: guidance section + read-only access cap, no tool list', () => {
|
||||
it('merges the built-in plan definition: a guidance section, nothing else', () => {
|
||||
const resolved = resolveConfig({})
|
||||
const plan = resolved.definitions.get(PLAN_MODE)
|
||||
expect(plan).toEqual({ section: plan?.section, access: 'read-only' })
|
||||
expect(plan).toEqual({ section: plan?.section })
|
||||
expect(plan?.section).toContain('plan mode')
|
||||
})
|
||||
|
||||
it('lets config override plan and add further modes', () => {
|
||||
const resolved = resolveConfig({ modes: {
|
||||
plan: { section: 'custom plan' },
|
||||
review: { section: 'review', access: 'workspace-write' },
|
||||
review: { section: 'review' },
|
||||
} })
|
||||
expect(resolved.definitions.get(PLAN_MODE)).toEqual({ section: 'custom plan' })
|
||||
expect(resolved.definitions.get('review')).toEqual({ section: 'review', access: 'workspace-write' })
|
||||
expect(resolved.definitions.get('review')).toEqual({ section: 'review' })
|
||||
})
|
||||
|
||||
it('rejects the reserved default key loudly', () => {
|
||||
@@ -108,18 +106,13 @@ describe('resolveConfig', () => {
|
||||
it('rejects a malformed definition loudly', () => {
|
||||
expect(() => resolveConfig({ modes: { bad: { section: 5 } as unknown as { section: string } } }))
|
||||
.toThrow('needs a string `section`')
|
||||
// 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.
|
||||
// Unknown keys fail loud — a tool allow/deny list and enforcement knobs
|
||||
// are 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', access: 'sealed' as never } } }))
|
||||
.toThrow('unknown access "sealed" — one of: read-only, workspace-write, danger-full-access')
|
||||
const resolved = resolveConfig({ modes: { locked: { section: 's', access: 'workspace-write' } } })
|
||||
expect(resolved.definitions.get('locked')).toEqual({ section: 's', access: 'workspace-write' })
|
||||
.toThrow('unknown key(s) tools — a definition is { section }')
|
||||
expect(() => resolveConfig({ modes: { bad: { section: '', access: 'read-only' } as unknown as { section: string } } }))
|
||||
.toThrow('unknown key(s) access — a definition is { section }')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -466,7 +459,7 @@ describe('the soft layer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the hard layer', () => {
|
||||
describe('no execution gating', () => {
|
||||
it('passes agent-less and default-mode executions through', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['write'])
|
||||
@@ -477,37 +470,24 @@ describe('the hard layer', () => {
|
||||
expect(defaulted.isError).toBe(false)
|
||||
})
|
||||
|
||||
it('runs non-shell calls in plan mode untouched — restraint outside the shell is the section, not a gate', async () => {
|
||||
it('runs every call in plan mode untouched — modes restrain by guidance, enforcement knobs are separate axes', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['read', 'write'])
|
||||
registerNamedTools(ctx, ['read', 'write', 'bash'])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const reading = await execute(ctx, 'read', agent)
|
||||
expect(reading.isError).toBe(false)
|
||||
const writing = await execute(ctx, 'write', agent)
|
||||
expect(writing.isError).toBe(false)
|
||||
for (const name of ['read', 'write', 'bash']) {
|
||||
const result = await execute(ctx, name, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('judges by the logged mode only — a pending plan intent neither gates nor clamps', async () => {
|
||||
it('treats a dropped folded definition as the default mode', 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 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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,17 +575,18 @@ describe('exit_plan_mode', () => {
|
||||
expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning'])
|
||||
})
|
||||
|
||||
it('an approved exit keeps the plan clamp until the boundary (same-batch policy holds)', async () => {
|
||||
it('an approved exit keeps the plan surface until the boundary (same-batch fold holds)', async () => {
|
||||
const { ctx, agent } = await setupWithReview({ selected: ['Approve'] })
|
||||
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
|
||||
const approved = await callExit(ctx, agent)
|
||||
expect(approved.isError).toBe(false)
|
||||
// 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')
|
||||
// Calls of the SAME assistant response (no boundary between) were
|
||||
// requested under the plan-shaped header — the fold stays plan for that
|
||||
// whole batch; the boundary flush is what flips the next step.
|
||||
expect(foldMode(agent.session.events)).toBe(PLAN_MODE)
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
expect(assembly.tools.some(tool => tool.name === EXIT_PLAN_MODE)).toBe(true)
|
||||
await boundary(ctx, agent, 'step/end')
|
||||
expect(await ctx.bash.resolveMode(agent.session)).toBe('workspace-write')
|
||||
expect(foldMode(agent.session.events)).toBe(DEFAULT_MODE)
|
||||
})
|
||||
|
||||
it('the exit flush narrates nothing — the tool result is the narration', async () => {
|
||||
@@ -706,188 +687,3 @@ 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 process 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,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 1000,
|
||||
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): BashProcess { throw new Error('unused in access-cap tests') }
|
||||
}
|
||||
|
||||
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', 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' } } })
|
||||
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 tool under an access cap', () => {
|
||||
it('exposes and admits bash — and leaves the generic task controls alone — under a confining executor', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(FakeSandboxExecutor, { mode: 'workspace-write' })
|
||||
registerNamedTools(ctx, ['read', 'write', 'bash', 'task_output', 'task_kill'])
|
||||
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', EXIT_PLAN_MODE, 'read', 'task_kill', 'task_output', 'write'])
|
||||
for (const name of ['bash', 'task_output', 'task_kill']) {
|
||||
const result = await execute(ctx, name, agent)
|
||||
expect(result.isError).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('hides and denies bash in plan mode without any executor; the kind-generic task controls stay', async () => {
|
||||
const ctx = await setup()
|
||||
registerNamedTools(ctx, ['read', 'bash', 'task_output', 'task_kill'])
|
||||
const agent = agentWithSession()
|
||||
agent.session.append('mode/set', { mode: PLAN_MODE })
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
// task_output/task_kill span every task kind (subagents included), so the
|
||||
// cap withholds only the starter it can reason about.
|
||||
expect(assembly.tools.map(tool => tool.name).sort()).toEqual([EXIT_PLAN_MODE, 'read', 'task_kill', 'task_output'])
|
||||
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: its read-only sandbox cap needs a sandboxing bash executor, and none is mounted',
|
||||
}])
|
||||
})
|
||||
|
||||
it('hides and denies bash in plan mode under a never-confining executor', async () => {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(FakeSandboxExecutor, {})
|
||||
registerNamedTools(ctx, ['read', 'bash'])
|
||||
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: its read-only sandbox cap needs a sandboxing bash executor, and none is mounted',
|
||||
}])
|
||||
})
|
||||
|
||||
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, ['bash'])
|
||||
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' } } })
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user