feat(modes): per-session sandbox/approval switching — the session log as the store, ACP config options

effective(session) = findLast(the session own knob events)?.value ?? the
composition-config default. One log-only event per knob, owned by its
domain (bash/sandbox-mode in dsh-bash, approval/policy in dsh-approval),
each exporting the same three-piece kit: the event declaration, a pure
fold, and THE write path — a switch IS its event; no owner service, no
facts map. Restart immunity and multi-session isolation fall out of the
log replay by construction.

Execution follows the fold on both sides: the bash tool stamps
escalation grant > session override > executor default, and the approval
seam prepends the never-gate that auto-rejects before any interactive
answerer. Visibility is two layers per knob: a per-agent prompt section
states the effective value on every request (logged through
request/header*, so what-the-model-was-told replays from the log), and an
agent/pre-step narrator injects at most one coalesced delta notice with
positional attribution (user switch vs operator/config drift). The ACP
bridge advertises one capability-gated select per composable knob with
currentValue folded per session, validates set_config_option against the
closed vocabularies, and anchors idle switches at the next turn
prompt-submit under the turn-enclosure contract.
This commit is contained in:
kingwl
2026-07-09 16:41:03 +08:00
parent 0e49615a3d
commit 3f663c9154
24 changed files with 1149 additions and 43 deletions

View File

@@ -17,7 +17,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105) — the c
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the strictly wider mode a denied command needs (`read-only` offers `workspace-write`/`danger-full-access`; `workspace-write` offers `danger-full-access`; nothing above `danger-full-access`, so the fields vanish). |
| `sandbox_permissions` | string enum | ADVERTISED ONLY when the mounted executor sandboxes (`ctx.bash.sandboxMode` reports a confining default): the wider mode a denied command needs, from the closed target vocabulary `workspace-write`/`danger-full-access` (never cut down to the executor's default — the effective mode is per-session; strict widening is checked at execution against it, and a non-widening request fails without prompting anyone). |
| `justification` | string | Required together with `sandbox_permissions` (each without the other is a validation error): one sentence for the user explaining why this exact command needs the wider access. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
@@ -52,5 +52,8 @@ The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks
Commands run with the executor's full authority unless a sandboxing executor ([`dsh-bash-sandbox`](../bash-sandbox/)) confines them — the deny-only sandbox reports denials as result facts, rendered here as the denial marker; per-call allow/deny/ask policy is the `tools/pre-execute` waterfall (see docs/architecture.md).
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../approval/approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the executor's default), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
On top of a denial sits the escalation gate ([the sandbox RFC § Escalation](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md)): an escalating call (`sandbox_permissions` + `justification`) resolves [`ctx.approval`](../../approval/approval/README.md) BEFORE anything executes — `allowed-once` stamps the granted mode onto the bash request as the seam-level `sandboxMode` override (that one call runs, classifies, and reports under the wider mode; its neighbors keep the session's effective mode), while `rejected`/`cancelled`/`unavailable` and the no-service / no-agent paths each fail closed with their own error text and execute nothing. The seam is consumed opportunistically (`ctx.get('approval')`, the dsh-tools ask-routing pattern); the grant is consumed by the very call that asked, and nothing is stored. The static description teaches — and a denied result itself prompts, via the escalation-available marker appended exactly when the fields are advertised — the SAME-TURN flow: on a denial a wider mode would cure, retry the exact command once with `sandbox_permissions` (the narrowest mode that suffices) + `justification` immediately, without detouring through chat (the approval prompt IS the user's consent); never speculatively — an escalation is grounded in a real denial (up-front only when the session already denied the same access), a prompt-stated approvals-disabled policy turns the exception off entirely, and a rejected escalation is final for that command.
## Per-session mode switching
Under a sandboxing executor this plugin makes the session's standing mode override ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/proposed/feature/2026-07-06-sandbox.md); the `bash/sandbox-mode` fold owned by [`dsh-bash`](../bash/README.md)) real at EXECUTION: every call is stamped `escalation grant > session override > undefined` onto `BashExecRequest.sandboxMode`; without either, the executor's `resolve()` applies its configured default. Nothing is stamped under a non-sandboxing executor (nothing would honor it) or for an agent-less caller (no session to fold). The prompt deliberately does NOT state the mode and a switch is not narrated: a standing declaration teaches the model to refuse preemptively, while the denial marker already names the mode the command ran under exactly when the boundary is hit — behavior, not belief, carries the state.

View File

@@ -41,8 +41,16 @@
* through `ctx.approval` BEFORE anything executes and failing closed on every
* unanswerable path. The fields exist only when the mounted executor reports
* a confining default (`ctx.bash.sandboxMode`) — a lever is never advertised
* that the composition cannot honor. Per-session mode switching is the
* sandbox RFC's staged follow-up.
* that the composition cannot honor.
*
* 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`.
* 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
* work a standing declaration would discourage.
*
* @module @deepseek-ai/dsh-tool-bash
*/
@@ -59,7 +67,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-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
export const name = 'tool-bash'
@@ -472,6 +480,19 @@ 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
@@ -493,10 +514,12 @@ 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, 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 = defaultMode as SandboxMode
// mode — session override ?? executor default, the same fold 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
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`)
}
@@ -564,9 +587,11 @@ 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).
const sandboxMode = args.sandbox_permissions !== undefined && args.justification !== undefined
? await approveEscalation(args.sandbox_permissions, args.justification, exec)
: undefined
: sessionOverride(exec)
// 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

@@ -4,8 +4,9 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
import { BashExecutor, BashTaskId, setSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -1135,7 +1136,7 @@ describe('sandbox rendering', () => {
describe('sandbox escalation (sandbox_permissions / justification)', () => {
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
async function setupSandboxed(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', opts: { approval?: boolean } = {}) {
async function setupSandboxed(mode?: 'read-only' | 'workspace-write' | 'danger-full-access', opts: { approval?: boolean; policy?: 'ask' | 'never' } = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -1144,7 +1145,7 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => {
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...mode !== undefined ? { mode } : {} })
const bash = ctx.bash as SandboxBashExecutor
bash.internals = { spillDir }
if (opts.approval === true) await ctx.plugin(ApprovalService)
if (opts.approval === true) await ctx.plugin(ApprovalService, opts.policy !== undefined ? { policy: opts.policy } : {})
await ctx.plugin(ToolBash)
return { ctx, bash }
}
@@ -1327,6 +1328,23 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => {
expect(text(result)).toContain('unreachable')
})
it('a never policy rejects an escalation deterministically without consulting any answerer', async () => {
// The live-session e.md case: the model requests escalation against a
// 'never' session — the prepend gate answers rejected before any
// interactive answerer, the fail-closed text is the ordinary rejection
// wording, and the audit pair still lands.
const { ctx } = await setupSandboxed('read-only', { approval: true, policy: 'never' })
const consulted = vi.fn()
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
const events: Array<{ type: string; data: Record<string, unknown> }> = []
const result = await callAs(ctx, escalationAgent(events), ESCALATE)
expect(result.isError).toBe(true)
expect(text(result)).toContain('the user rejected escalating this command to "workspace-write"')
expect(consulted).not.toHaveBeenCalled()
expect(events.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
expect(events[1]?.data).toMatchObject({ outcome: 'rejected' })
})
it('a plain call under a sandboxing executor never consults approval', async () => {
const { ctx } = await setupSandboxed('read-only', { approval: true })
const asked = vi.fn()
@@ -1337,3 +1355,122 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => {
expect(asked).not.toHaveBeenCalled()
})
})
describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => {
/** Compose the real sandbox stack (passthrough runner) at a given default mode. */
async function setupModal(mode: 'read-only' | 'workspace-write' | 'danger-full-access' = 'read-only', opts: { approval?: boolean } = {}) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalSandboxProvider, { runnerCommand: PASSTHROUGH_RUNNER })
await ctx.plugin(SandboxBashExecutor, { graceMs: 200, mode })
;(ctx.bash as SandboxBashExecutor).internals = { spillDir }
if (opts.approval === true) await ctx.plugin(ApprovalService)
await ctx.plugin(ToolBash)
return ctx
}
/**
* An agent stand-in over a REAL Session — the stamping folds real events;
* the opened turn satisfies approval's enclosure precondition on escalating
* calls.
*/
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
const session = new Session(SessionId(id))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const injected: string[] = []
const agent = {
id,
session,
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
} as unknown as Agent
return { agent, session, injected }
}
let modeCall = 0
const callAs = (ctx: Context, agent: Agent | undefined, args: unknown) =>
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 () => {
const ctx = await setupModal('read-only', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
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-stamp-1')
const run = { command: 'true', description: 'stamp probe' }
await callAs(ctx, agent, run) // no override yet
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, agent, { ...run, sandbox_permissions: 'danger-full-access', justification: 'grant outranks override' })
expect(seen).toEqual([undefined, 'workspace-write', undefined, 'danger-full-access'])
})
it('escalates relative to the session effective mode, not the executor default (narrower override)', async () => {
// The blocker scenario: a workspace-write default with a read-only
// override — the sensible escalation is workspace-write, which a
// default-relative ladder could not even express. The static target
// vocabulary advertises it and the execution check accepts it as
// strictly wider than the CALL's effective (overridden) mode.
const ctx = await setupModal('workspace-write', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
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-esc-narrow')
setSandboxMode(session, 'read-only')
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'the override is narrower than the default' })
expect(result.isError).toBe(false)
expect(seen).toEqual(['workspace-write'])
})
it('a danger-full-access default still offers the lever to a narrower-switched session', async () => {
// Under the default-relative ladder these fields VANISHED (nothing is
// wider than the default), stranding a read-only-overridden session
// with no escalation path at all.
const ctx = await setupModal('danger-full-access', { approval: true })
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
const schema = ctx.tools.schemas().find(t => t.name === 'bash') as unknown as { parameters: { properties: Record<string, { enum?: string[] }> } }
expect(schema.parameters.properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
const { agent, session } = sessionAgent('sess-esc-dfa')
setSandboxMode(session, 'read-only')
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'confined by override under a wide default' })
expect(result.isError).toBe(false)
})
it('rejects a non-widening request against the OVERRIDDEN effective mode without prompting', async () => {
const ctx = await setupModal('read-only', { approval: true })
const consulted = vi.fn()
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
const { agent, session } = sessionAgent('sess-esc-nonwide')
setSandboxMode(session, 'danger-full-access')
const result = await callAs(ctx, agent, { command: 'true', description: 'd', sandbox_permissions: 'workspace-write', justification: 'already wider via override' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('not strictly wider than this call\'s current "danger-full-access" mode')
expect(consulted).not.toHaveBeenCalled()
})
it('never stamps an override under a non-sandboxing executor (nothing honors it)', async () => {
const ctx = await setup()
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-stamp-2')
setSandboxMode(session, 'danger-full-access')
await callAs(ctx, agent, { command: 'true', description: 'plain probe' })
expect(seen).toEqual([undefined])
})
})