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

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