Merge remote-tracking branch 'origin/master' into codex/simp-prune-sandbox-surface

# Conflicts:
#	packages/bash/bash-sandbox/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-15 22:55:55 +08:00
128 changed files with 5255 additions and 2706 deletions

View File

@@ -10,15 +10,15 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
|---|---|
| `read-only` (default) | No writes anywhere (of `/dev`, only the `/dev/null` node is writable, so `>/dev/null` keeps working) |
| `workspace-write` | Writes only under `workspaceRoot` + `/tmp` (ephemeral under bwrap, the host `/tmp` under Landlock, `/private/tmp` plus the per-user temp dir under Seatbelt) |
| `danger-full-access` | No confinement; the provider is never consulted. Execution is `dsh-bash-local`'s verbatim — foreground results still carry `sandbox: { mode, denied: false }` (no `enforcement`: nothing was confined), background tasks carry no sandbox facts |
| `danger-full-access` | No confinement; the provider is never consulted. Foreground results carry `sandbox: { mode, denied: false }`; background process handles carry no sandbox facts. |
Semantics:
- **Denials are result facts.** A failed run whose stderr carries the selected backend's own denial dialect — the signatures the provider stamps on every wrap (EROFS text under bwrap, EACCES under Landlock, EPERM under Seatbelt) — is reported as `BashRunResult.sandbox.denied: true` (conservative classification, read from the collected stderr tail); every CONFINED run also carries the mode it executed under (`result.sandbox.mode`) and the provider's enforcement completeness (`result.sandbox.enforcement`: `full`, or `partial` on an older Landlock ABI).
- **Runner failures are sandbox failures, never task failures.** A failed run matching the wrap's `runnerFailureSignatures` (the runner's own error prefix — also what the shell prints for a missing runner) means the sandbox itself broke and the command NEVER RAN; the check outranks denial classification because a runner's error text can contain denial words. The foreground path re-throws it as the structured fail-closed `SANDBOX_UNAVAILABLE` error, with the runner's first stderr line as the cause; a settled background task stamps `task.sandbox.runnerFailed` instead (no error channel remains after settle), which `bash_output` renders as its own marker.
- **Config default, per-call override.** `resolve()` stamps the configured sandbox mode onto each spec unless an approved request supplies a wider mode. That override affects only its call or background task. `ctx.bash.sandboxMode` reports the default so the tool advertises escalation only when supported; results report the effective mode. The model learns standing mode only from tool/result facts, not a system-prompt announcement.
- **Runner failures are sandbox failures, never command failures.** Foreground execution throws `SANDBOX_UNAVAILABLE`; a settled background process stamps `process.sandbox.runnerFailed`, which the bash producer renders through generic `task_output`. Spawn failures also pass through settlement, so confined background handles retain their mode/enforcement facts and release per-process accounting.
- **Config-time default, per-call policy.** The DEFAULT mode is fixed by this entry's config for the executor's lifetime; `resolve()` stamps it onto every spec, and an explicit request-level `sandboxMode` override — set by the tool layer only for a call whose wider mode a human granted through `ctx.approval` ([the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)) — makes THAT call run, classify, and report under its own mode while every neighbor keeps the default (background facts are stamped per task at settle). The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted. The model learns of the sandbox only through result facts — the static bash tool description explains the denial marker; there is no current-mode statement in the system prompt.
- **File effects only.** Network and process visibility are deliberately not restricted — the mode vocabulary does not pretend to cover what the backend does not enforce.
- Process mechanics (spawn, process-group kills, output collection/spill, background tasks, credential scrub) are inherited verbatim from [`dsh-bash-local`](../bash-local/); the runner ladder, probes, and the per-platform Landlock launcher packages live with [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
- Process mechanics (spawn, process-group kills, output collection/spill, background handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
Deny-only at the seam: a denial is a reported fact, and this executor never negotiates permissions itself — the approval question lives in the tool layer (`dsh-tool-bash`), which drives the override this package honors.
@@ -58,5 +58,5 @@ The keyless consumer-integration proofs are `tests/bwrap.e2e.ts`, `tests/landloc
- **Confinement covers file effects only** — network access and process visibility are unchanged, so the modes are not a general-purpose security sandbox.
- **Denials are inferred from failed-command stderr** — backend signatures make the inference portable, but a matching application error can be classified as a denial and a denial omitted from the retained tail can be missed.
- **A background runner failure has no immediate error channel** — it is recorded on the settled task and surfaces when the caller polls with `bash_output`.
- **A background runner failure has no immediate error channel** — it is recorded on the settled process and surfaces when the caller reads the generic task with `task_output`.
- **`danger-full-access` deliberately bypasses `ctx.sandbox`** — it is an explicit unconfined mode, not a wider sandbox profile.

View File

@@ -3,14 +3,14 @@
* `ctx.sandbox`, inherits local process mechanics, and reports the selected
* mode, enforcement, and denial facts. Runner failure means the command never
* ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while settled background
* tasks carry `runnerFailed`. The tool owns approval and passes per-call modes.
* processes carry `runnerFailed`. The tool owns approval and passes per-call modes.
* @module @deepseek-ai/dsh-bash-sandbox
*/
import { resolve } from 'node:path'
import { Context } from 'cordis'
import z from 'schemastery'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
@@ -21,7 +21,7 @@ import { classifyDenial, classifyRunnerFailure, matchesSignature, shellQuote } f
* Plugin config: the local executor's knobs plus the sandbox policy. All
* optional — `static Config` supplies the defaults (`mode: 'read-only'` is the
* fail-safe default; an example that wants a workspace-writable agent opts in
* explicitly). The runner choice is NOT configured here: which platform
* explicitly). The runner choice is not configured here: which platform
* backend confines the command is the `ctx.sandbox` provider's config.
*/
export interface Config extends LocalConfig {
@@ -57,11 +57,12 @@ export class SandboxBashExecutor extends LocalBashExecutor {
private readonly mode: SandboxMode
private readonly workspaceRoot: string
/**
* Per-task mode and wrap facts retained until settlement. Overlapping tasks
* may use different modes or provider facts, so one latest-wrap field would
* misclassify earlier completions.
* Per-process confinement facts retained until settlement. Providers may
* vary enforcement and diagnostic dialect between overlapping calls, so a
* shared latest-wrap value would classify a process against the wrong facts.
* Unconfined processes have no entry.
*/
private readonly taskFacts = new Map<BashTaskId, {
private readonly processFacts = new Map<BashProcess, {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
@@ -70,10 +71,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
constructor(ctx: Context, config: Config) {
super(ctx, config)
// schemastery (static Config) already filled the defaulted fields — the
// cast records that runtime fact (mirrors LocalBashExecutor's config
// cast). `workspaceRoot` and `cwd` have NO schema default, so their
// fallback chain is real branching.
// Schemastery fills mode before construction; workspaceRoot and cwd retain runtime fallbacks.
this.mode = config.mode as SandboxMode
this.workspaceRoot = resolve(config.workspaceRoot ?? config.cwd ?? process.cwd())
}
@@ -111,39 +109,36 @@ export class SandboxBashExecutor extends LocalBashExecutor {
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
override start(spec: BashExecSpec): BashTask {
override start(spec: BashExecSpec): BashProcess {
// Same stamped-by-resolve invariant as run().
const mode = spec.sandboxMode as SandboxMode
if (mode === 'danger-full-access') return super.start(spec)
// Classification needs settled stderr. Store facts synchronously after
// spawn, before the earliest process completion can be observed.
// Install facts synchronously; promise settlement cannot run before start() returns.
const confined = this.confine(spec.command, mode)
const task = super.start({ ...spec, command: confined.command })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.taskFacts.set(task.id, { mode, enforcement, denialSignatures, runnerFailureSignatures })
return task
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
return proc
}
/**
* Stamp per-task sandbox facts before completion listeners and `done` settle.
* Full-access tasks have no facts; signal deaths are not denials.
* Stamp per-process sandbox facts before `done` settles. Full-access processes
* have no facts; signal deaths are not denials.
*/
protected override notifyTaskDone(task: BashTask): void {
const facts = this.taskFacts.get(task.id)
protected override onProcessDone(proc: BashProcess, stderr: string): void {
const facts = this.processFacts.get(proc)
if (facts !== undefined) {
this.taskFacts.delete(task.id)
const stderr = this.collectedStderr(task.id)
// Runner failure outranks denial. Background settlement has no throw
// channel, so this fact is its counterpart to the foreground exception.
const runnerFailed = matchesSignature(task.exitCode, stderr, facts.runnerFailureSignatures)
task.sandbox = {
this.processFacts.delete(proc)
// Runner failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(task.exitCode, stderr, facts.denialSignatures),
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
enforcement: facts.enforcement,
...(runnerFailed ? { runnerFailed } : {}),
}
}
super.notifyTaskDone(task)
super.onProcessDone(proc, stderr)
}
/**

View File

@@ -133,7 +133,7 @@ describe('danger-full-access', () => {
const task = bash.start(bash.resolve({ command: 'echo free-bg' }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(bash.readOutput(task.id).delta).toContain('free-bg')
expect(task.readOutput().delta).toContain('free-bg')
expect(calls).toHaveLength(0)
})
})
@@ -185,7 +185,7 @@ describe('per-call sandboxMode override (the escalation mechanism)', () => {
const task = bash.start(bash.resolve({ command: 'echo bg-free', sandboxMode: 'danger-full-access' }))
await task.done
expect(task.sandbox).toBeUndefined()
expect(bash.readOutput(task.id).delta).toContain('bg-free')
expect(task.readOutput().delta).toContain('bg-free')
expect(calls).toHaveLength(0)
})
})
@@ -244,6 +244,20 @@ describe('result facts', () => {
})
describe('background sandbox facts', () => {
it('stamps facts and releases accounting when background spawn fails', async () => {
const { bash } = await setup()
const missingWorkdir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-')), 'missing')
const task = bash.start(bash.resolve({ command: 'true', workdir: missingWorkdir }))
await task.done
expect(task.status).toBe('killed')
expect(task.readOutput().delta).toContain('spawn failed:')
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
})
it('stamps a settled denial: nonzero exit + permission stderr under a confined mode', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
@@ -274,15 +288,6 @@ describe('background sandbox facts', () => {
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full', runnerFailed: true })
})
it('completion listeners already see the stamped facts (stamp precedes notify)', async () => {
const { ctx, bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const seen: unknown[] = []
ctx.bash.onTaskDone((task) => { seen.push(task.sandbox) })
const task = bash.start(bash.resolve({ command: 'echo "x: Permission denied" >&2; exit 1' }))
await task.done
expect(seen).toEqual([{ mode: 'read-only', denied: true, enforcement: 'partial' }])
})
it('overlapping background tasks keep their OWN wrap facts (per-task, not latest-wrap)', async () => {
// Facts belong to each wrap and may vary between calls. The slow task settles after the
// quick task starts; a shared latest-wrap field would classify and stamp it with the wrong
@@ -309,8 +314,8 @@ describe('background sandbox facts', () => {
const task = bash.start(bash.resolve({ command: 'echo "Permission denied" >&2; sleep 30' }))
// Let the stderr land before the kill so the classifier sees the
// signature and must still refuse it on the null exit code alone.
await vi.waitFor(() => { expect(bash.readOutput(task.id).delta).toContain('Permission denied') })
bash.kill(task.id)
await vi.waitFor(() => { expect(task.readOutput().delta).toContain('Permission denied') })
task.kill()
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
})