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 () => {