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:
kingwl
2026-07-20 13:34:30 +08:00
parent b47b2ba794
commit c0146b9c4a
38 changed files with 1432 additions and 2082 deletions
+6 -13
View File
@@ -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 }