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

View File

@@ -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).

View File

@@ -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

View File

@@ -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']
/**

View File

@@ -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])
})
})

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

View File

@@ -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 }