fix(sandbox): evidence-gate runner failures (round 1)

This commit is contained in:
Hypatia May
2026-08-03 17:31:08 +08:00
parent 6a3caea277
commit 6343d8f6e6
51 changed files with 805 additions and 140 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bash/bash-sandbox/README.md
README.md: 035a8ad2401ca608d264049d454359eda7b2b9a7
README.zh.md: cee27a9baaa539ba07eb1d730ea9bef2004fbeeb
README.md: 851bedea3589efe164cf49b7e6db1a5f3d64df24
README.zh.md: f4eb13dd11f8a22dd0a0d594a615567571b1ccde

View File

@@ -17,7 +17,7 @@ Every command is confined by handing the provider the exact `['bash', '-c', comm
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 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.
- **Runner failures are sandbox failures, never command failures.** Foreground and background execution use the same structured classifier: a rule's optional exit-code gate and a remaining fatal stderr line must both match after exact informational-line exclusions. A match outranks denial; foreground execution throws `SANDBOX_UNAVAILABLE` with the matched fatal line, while 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.
- **Deployment fallback, per-call policy.** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) resolves a complete `SandboxExecutionPolicy` for every tool call: the calling session supplies its mode override and immutable cwd root, while deployment config supplies the fallbacks for agentless calls. An approved escalation changes only that policy's mode; its session root stays attached. `resolve()` carries the policy onto the spec, so overlapping commands from different projects run, classify, and report under their own roots and modes. The capability fact `ctx.bash.sandboxMode` reports the configured default so the tool layer advertises escalation only when this executor is mounted; the static bash tool description separately owns denial and escalation guidance.
- **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 handles, credential scrub) are inherited from [`dsh-bash-local`](../bash-local/); runner selection lives in [`dsh-sandbox-local`](../../sandbox/sandbox-local/).
@@ -72,7 +72,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the first stderr line as its detail.
If no runner can enforce a confined mode, the foreground call propagates the [`SANDBOX_UNAVAILABLE` error owned by `dsh-sandbox`](../../sandbox/sandbox/README.md#confinement-error-indirectly). For an execution-time runner failure, this backend supplies the matched fatal stderr line as its detail and preserves the original stderr collection.
#### Token effect

View File

@@ -17,7 +17,7 @@
语义:
- **拒绝是结果事实。** 如果一次失败运行的 stderr 包含所选后端自身的拒绝方言,即提供方在每次包装时加上的特征(bwrap 下的 EROFS 文本、Landlock 下的 EACCES、Seatbelt 下的 EPERM),则结果报告 `BashRunResult.sandbox.denied: true`(从已收集的 stderr 尾部进行保守分类)。每次受限制运行还会携带执行时模式(`result.sandbox.mode`)与提供方强制执行完整性(`result.sandbox.enforcement`:`full`,或在较旧 Landlock ABI 上为 `partial`)。
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台执行会抛出 `SANDBOX_UNAVAILABLE`;已结算的后台进程会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **Runner 失败是沙箱失败,绝不是命令失败。** 前台与后台执行使用同一个结构化分类器:先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`,Bash 结果生成方通过通用 `task_output` 渲染它。spawn 失败也会经过结算,因此受限制的后台句柄会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`ctx.sandboxPolicy`](../../sandbox/sandbox-policy/) 为每次工具调用解析完整的 `SandboxExecutionPolicy`:调用会话提供自身的模式覆盖与不可变 cwd 根目录,部署配置则为无 agent(智能体)调用提供回退。已批准的升权只更改该策略的模式,会话根目录仍然附着其上。`resolve()` 把策略带入 spec,因此来自不同项目的重叠命令会在各自的根目录与模式下运行、分类和报告。能力事实 `ctx.bash.sandboxMode` 报告已配置的默认值,因此工具层只在装载该执行器时才公布升权;静态 bash 工具描述则单独负责拒绝与升级引导。
- **只限制文件影响。** 设计上不限制网络与进程可见性:模式词汇不会声称覆盖后端未强制执行的范围。
- 进程机制(spawn、进程组终止、输出收集/spill、后台句柄、凭证清理)继承自 [`dsh-bash-local`](../bash-local/);runner 选择位于 [`dsh-sandbox-local`](../../sandbox/sandbox-local/)。
@@ -72,7 +72,7 @@
#### 模型看到的内容
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供第一行 stderr 作为详细信息。
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。如果 runner 在执行时失败,此后端会提供匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
#### Token 影响

View File

@@ -5,6 +5,13 @@
*/
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
detail: string
}
/**
* Quote one string as a single-quoted POSIX shell word.
@@ -26,13 +33,33 @@ export function classifyDenial(result: BashRunResult, signatures: readonly strin
}
/**
* Classify a failed run against the selected backend's runner-failure dialect.
* @param result - settled foreground run.
* @param signatures - case-insensitive runner-failure substrings from the active wrap.
* @returns whether the failed run matches that runner-failure dialect.
* Classify one settled process against the selected backend's structured
* runner-failure rules. Each rule requires a nonzero exit, its optional
* exit-code gate, and a fatal signature on one stderr line after exact
* informational lines are excluded.
* @param exitCode - process exit code; null means signal termination.
* @param stderr - collected stderr text, left unchanged.
* @param rules - structured runner-failure rules from the active wrap.
* @returns the first matching fatal line, or undefined when evidence is insufficient.
*/
export function classifyRunnerFailure(result: BashRunResult, signatures: readonly string[]): boolean {
return matchesSignature(result.exitCode, result.stderr.text, signatures)
export function classifyRunnerFailure(
exitCode: number | null,
stderr: string,
rules: readonly RunnerFailureRule[],
): RunnerFailureMatch | undefined {
if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
const fatalSignatures = rule.fatalSignatures.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()
if (informationalLines.has(lowered)) continue
if (fatalSignatures.some(signature => lowered.includes(signature))) return { detail: line }
}
}
return undefined
}
/**

View File

@@ -11,7 +11,14 @@
import { Context } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash'
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedSandboxMode, SandboxEnforcement, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type {
ConfinedSandboxMode,
RunnerFailureRule,
SandboxEnforcement,
SandboxExecutionPolicy,
SandboxMode,
SandboxPolicy,
} from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-bash-local'
@@ -51,7 +58,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
mode: ConfinedSandboxMode
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
}>()
constructor(ctx: Context, config: Config) {
@@ -84,10 +91,11 @@ export class SandboxBashExecutor extends LocalBashExecutor {
}
const confined = this.confine(spec.command, { ...policy, mode })
const result = await super.run({ ...spec, command: confined.command })
// Runner failure outranks denial because the command did not run. Throw the
// same fail-closed error as confine-time discovery with the first stderr line.
if (classifyRunnerFailure(result, confined.runnerFailureSignatures)) {
throw new SandboxUnavailableError(mode, result.stderr.text.trim().split('\n')[0])
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }
}
@@ -99,8 +107,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// Install facts synchronously; promise settlement cannot run before start() returns.
const confined = this.confine(spec.command, { ...policy, mode })
const proc = super.start({ ...spec, command: confined.command })
const { enforcement, denialSignatures, runnerFailureSignatures } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureSignatures })
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules })
return proc
}
@@ -113,7 +121,7 @@ export class SandboxBashExecutor extends LocalBashExecutor {
if (facts !== undefined) {
this.processFacts.delete(proc)
// Runner failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = matchesSignature(proc.exitCode, stderr, facts.runnerFailureSignatures)
const runnerFailed = classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),
@@ -136,14 +144,14 @@ export class SandboxBashExecutor extends LocalBashExecutor {
command: string
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
} {
const confined = this.ctx.sandbox.confine(['bash', '-c', command], policy)
return {
command: `exec ${confined.argv.map(shellQuote).join(' ')}`,
enforcement: confined.enforcement,
denialSignatures: confined.denialSignatures,
runnerFailureSignatures: confined.runnerFailureSignatures,
runnerFailureRules: confined.runnerFailureRules,
}
}
}

View File

@@ -0,0 +1,125 @@
/**
* Deterministic composition proof for the partial-Landlock diagnostic: the
* real local provider and sandbox bash executor wrap commands through a POSIX
* fake launcher that prints the native informational line before exec.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
const NOTICE = 'landlock-run: partial enforcement (older Landlock ABI)'
const FATAL = 'landlock-run: landlock ruleset error: Invalid argument'
const contexts: Context[] = []
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
})
/** Write a fake native launcher that reports partial enforcement, then execs or fails. */
async function fakeLauncher(fatal: boolean): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-partial-landlock-'))
tempDirs.push(dir)
const launcher = join(dir, 'landlock-run')
const fatalBranch = fatal ? `printf '%s\\n' '${FATAL}' >&2\nexit 125\n` : ''
await writeFile(launcher, `#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
--ro|--rw) shift 2 ;;
--) shift; break ;;
*) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;;
esac
done
printf '%s\\n' '${NOTICE}' >&2
${fatalBranch}exec "$@"
`, { mode: 0o755 })
return launcher
}
async function setup(fatal = false): Promise<SandboxBashExecutor> {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: await fakeLauncher(fatal),
}
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: process.cwd() })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: process.cwd(), timeoutMs: 5_000 })
return ctx.bash as SandboxBashExecutor
}
describe('partial Landlock runner-failure classification', () => {
it('keeps true, false, and child exit 125 as child outcomes when the notice is the only runner line', async () => {
const bash = await setup()
for (const [command, exitCode] of [['true', 0], ['false', 1], ['exit 125', 125]] as const) {
const result = await bash.run(bash.resolve({ command }))
expect(result.exitCode).toBe(exitCode)
expect(result.stderr.text).toBe(`${NOTICE}\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
}
})
it('reports the fatal line after the notice as SANDBOX_UNAVAILABLE detail', async () => {
const bash = await setup(true)
const error = await bash.run(bash.resolve({ command: 'true' })).catch((value: unknown) => value)
expect(error).toMatchObject({ name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE })
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain(`Runner failure: ${FATAL}`)
expect((error as Error).message).not.toContain(NOTICE)
})
it('classifies a notice plus child Permission denied as a denial, not runner failure', async () => {
const bash = await setup()
const result = await bash.run(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
expect(result.stderr.text).toBe(`${NOTICE}\nchild: Permission denied\n`)
expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
})
it('applies the same evidence rule to notice-only background exits', async () => {
const bash = await setup()
for (const command of ['false', 'exit 125']) {
const task = bash.start(bash.resolve({ command }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
}
})
it('classifies a background notice plus child Permission denied as denial', async () => {
const bash = await setup()
const task = bash.start(bash.resolve({ command: 'printf "%s\\n" "child: Permission denied" >&2; exit 1' }))
await task.done
expect(task.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
expect(task.readOutput().delta).toContain(NOTICE)
})
it('makes a background fatal line outrank denial text after the notice', async () => {
const bash = await setup(true)
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(task.sandbox).toEqual({
mode: 'read-only',
denied: false,
enforcement: 'partial',
runnerFailed: true,
})
const output = task.readOutput().delta
expect(output).toContain(NOTICE)
expect(output).toContain(FATAL)
})
})

View File

@@ -30,12 +30,12 @@ interface ConfineCall {
/** The Linux file-denial dialects the fake wraps carry — matches the unix-permission denials the tests below produce. */
const UNIX_SIGNATURES = ['read-only file system', 'permission denied'] as const
/** The runner-failure prefix the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = ['fake-runner: '] as const
/** The runner-failure rule the fake wraps carry (a fake-runner: error line marks the sandbox itself failing). */
const RUNNER_FAILURE = [{ fatalSignatures: ['fake-runner: '] }] as const
/** A passthrough wrap: the caller's argv unchanged, asserted full — commands run unconfined, deterministically. */
const passthrough = (argv: readonly string[]): ConfinedArgv =>
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE })
({ argv: [...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE })
/**
* Boot a context with a recording fake `ctx.sandbox` (behavior injectable
@@ -93,7 +93,7 @@ describe('the provider hand-off', () => {
it('a wrapped argv from the provider is what actually spawns (prefix survives, quoting round-trips)', async () => {
// The fake wraps with `env MARKER=...` — a real (if tiny) runner prefix:
// the sentinel only prints if the executor spawned the WRAPPED argv.
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const { bash } = await setup({}, argv => ({ argv: ['env', 'DSH_WRAP=1', ...argv], enforcement: 'full', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'printf "%s" "$DSH_WRAP"' }))
expect(result.stdout.text).toBe('1')
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
@@ -234,10 +234,47 @@ describe('classifyDenial', () => {
})
describe('classifyRunnerFailure', () => {
it('matches the dialect case-insensitively on BOTH sides — the seam declares it so, and producers compose signatures from runtime data (an argv0 path, the shell\'s `No such file or directory`)', () => {
const signatures = ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory']
expect(classifyRunnerFailure(runResult(127, 'bash: /Opt/Runners/bwrap: No such file or directory'), signatures)).toBe(true)
expect(classifyRunnerFailure(runResult(127, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND'), signatures)).toBe(true)
it('matches an outer-shell rule case-insensitively only at its exit codes and configured argv0', () => {
const rules = [{
allowedExitCodes: [126, 127],
fatalSignatures: ['exec: /Opt/Runners/bwrap: not found', '/Opt/Runners/bwrap: No such file or directory'],
}]
expect(classifyRunnerFailure(127, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)?.detail)
.toBe('bash: /Opt/Runners/bwrap: No such file or directory')
expect(classifyRunnerFailure(126, 'BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND', rules)?.detail)
.toBe('BASH: LINE 1: EXEC: /OPT/RUNNERS/BWRAP: NOT FOUND')
expect(classifyRunnerFailure(125, 'bash: /Opt/Runners/bwrap: No such file or directory', rules)).toBeUndefined()
expect(classifyRunnerFailure(127, 'bash: /other/bwrap: No such file or directory', rules)).toBeUndefined()
})
it('requires Landlock exit 125 plus a non-notice fatal line and returns that original line', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const rules = [{ allowedExitCodes: [125], fatalSignatures: ['landlock-run: '], informationalLines: [notice] }]
expect(classifyRunnerFailure(1, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(2, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, rules)).toBeUndefined()
expect(classifyRunnerFailure(125, notice.toUpperCase(), rules)).toBeUndefined()
expect(classifyRunnerFailure(125, `${notice}: extra detail`, rules))
.toEqual({ detail: `${notice}: extra detail` })
expect(classifyRunnerFailure(125, `${notice}\nlandlock-run: exec failed: No such file or directory`, rules))
.toEqual({ detail: 'landlock-run: exec failed: No such file or directory' })
})
it.each([
'landlock-run: usage error: missing `-- <argv>...` command',
'landlock-run: landlock is not enforced by this kernel (ABI unsupported or disabled)',
'landlock-run: cannot open rule path: /gone: No such file or directory',
'landlock-run: landlock ruleset error: Invalid argument',
'landlock-run: exec failed: Permission denied',
'landlock-run: out of memory',
'landlock-run: future fatal diagnostic',
])('keeps known and future Landlock fatal diagnostics fail-closed: %s', (fatal) => {
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
}]
expect(classifyRunnerFailure(125, fatal, rules)).toEqual({ detail: fatal })
})
})
@@ -253,7 +290,7 @@ describe('result facts', () => {
})
it('carries the provider\'s partial-enforcement fact through unchanged', async () => {
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureSignatures: RUNNER_FAILURE }))
const { bash } = await setup({}, argv => ({ argv: [...argv], enforcement: 'partial', denialSignatures: UNIX_SIGNATURES, runnerFailureRules: RUNNER_FAILURE }))
const result = await bash.run(bash.resolve({ command: 'true' }))
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
})
@@ -284,7 +321,7 @@ describe('background sandbox facts', () => {
it('a foreground runner failure throws the fail-closed error, never a task result', async () => {
// The wrap's runner prefix on a failed run means the SANDBOX broke and
// the command never ran — the late twin of the confine-time throw, with
// the runner's own first stderr line carried as the cause.
// the matched fatal stderr line carried as the cause.
const { bash } = await setup()
const run = bash.run(bash.resolve({ command: 'echo "fake-runner: ruleset rejected" >&2; exit 125' }))
await expect(run).rejects.toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
@@ -315,7 +352,7 @@ describe('background sandbox facts', () => {
let call = 0
const { bash } = await setup({}, (argv) => {
const wrap = wraps[Math.min(call++, wraps.length - 1)] as Pick<ConfinedArgv, 'enforcement' | 'denialSignatures'>
return { argv: [...argv], ...wrap, runnerFailureSignatures: RUNNER_FAILURE }
return { argv: [...argv], ...wrap, runnerFailureRules: RUNNER_FAILURE }
})
const slow = bash.start(bash.resolve({ command: 'sleep 0.4; echo "x: Permission denied" >&2; exit 1' }))
const quick = bash.start(bash.resolve({ command: 'true' }))

View File

@@ -1801,7 +1801,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ConfinedArgv',
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureSignatures: readonly string[];\n}',
declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}',
},
{
name: 'ConfinedSandboxMode',
@@ -2347,6 +2347,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'RunnerFailureRule',
declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}',
},
{
name: 'SandboxEnforcement',
declaration: 'export type SandboxEnforcement = \'full\' | \'partial\';',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md
README.md: b12ffda9869c7d6bef5ea5b54594781ecf555ff4
README.zh.md: 7dd6cdf9a209f2fe357b4ffe48d20d574266ce60
README.md: 93df279e958c569cd0b30618d3c3e80f773d452b
README.zh.md: 5cec79577dc896c2a95f9539a9911adcdc23cfe1

View File

@@ -46,7 +46,7 @@ Raw `rg` stdout is an internal transport detail. Each search requests `stdoutMax
## Errors
Search failures carry the package-owned `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
Search-owned failures carry `SearchError` (a `HarnessError` subclass), surfaced as `{ name, code }` on `isError` results: `SEARCH_INVALID_PATTERN` (ripgrep rejected the regex/glob), `SEARCH_FAILED` (runtime `rg` disappearance after registration, inaccessible target, signal kill, malformed `--json` output), `SEARCH_RAW_OUTPUT_OVERFLOW` (raw output over `rawOutputMaxBytes`, or still truncated after the requested stdout capture budget), and `SEARCH_ABORTED` (tool timeout, caller cancellation, or the bash executor's own timeout). An existing structured `HarnessError` rejected by the bash executor, including `SANDBOX_UNAVAILABLE`, propagates unchanged; only an untyped spawn, cwd, or shell-start rejection becomes `SEARCH_FAILED`, while an aborted signal remains `SEARCH_ABORTED`. ripgrep exit semantics are tool-owned: exit 0 is success with results, exit 1 is a successful empty search (`No files found` / `No matches found`), and only other exits are failures. Model argument mistakes (blank pattern, a list-valued `include`) stay ordinary tool argument errors.
## Model Experience
@@ -114,7 +114,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
Failures are normalized as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata for callers.
Search-owned failures render as `Error: <message>` with structured `SEARCH_INVALID_PATTERN`, `SEARCH_FAILED`, `SEARCH_RAW_OUTPUT_OVERFLOW`, or `SEARCH_ABORTED` metadata; structured bash-executor failures retain their owning name and code.
#### Token effect

View File

@@ -46,7 +46,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh-
## 错误
搜索失败携带本包拥有的 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。
搜索层自身的失败携带 `SearchError`(`HarnessError` 子类),以 `{ name, code }` 公开在 `isError` 结果上:`SEARCH_INVALID_PATTERN`(ripgrep 拒绝正则/glob)、`SEARCH_FAILED`(注册后 `rg` 在运行时消失、目标不可访问、信号终止、`--json` 输出格式错误)、`SEARCH_RAW_OUTPUT_OVERFLOW`(原始输出超过 `rawOutputMaxBytes`,或在请求 stdout 捕获预算后仍被截断)和 `SEARCH_ABORTED`(工具超时、调用方取消或 bash 执行器自身超时)。bash 执行器拒绝并返回的既有结构化 `HarnessError`(包括 `SANDBOX_UNAVAILABLE`)会原样传播;只有无类型的 spawn、cwd 或 shell 启动拒绝会转换为 `SEARCH_FAILED`,中止信号仍为 `SEARCH_ABORTED`。ripgrep 退出语义由工具拥有:退出 0 表示成功且有结果,退出 1 表示成功的空搜索(`No files found` / `No matches found`),只有其他退出值表示失败。模型参数错误(空白 pattern、列表值 `include`)仍是普通工具参数错误。
## 模型体验
@@ -114,7 +114,7 @@ glob 描述会说明配置所指定的超限结果排序方式。已生成的 [`
#### 模型看到的内容
失败会规范化为 `Error: <message>`,并向调用方提供结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据。
搜索层自身的失败会渲染为 `Error: <message>`,并附带结构化的 `SEARCH_INVALID_PATTERN`、`SEARCH_FAILED`、`SEARCH_RAW_OUTPUT_OVERFLOW` 或 `SEARCH_ABORTED` 元数据;来自 bash 执行器的结构化失败则保留其原有名称和错误码。
#### Token 影响

View File

@@ -44,10 +44,14 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-bash-sandbox": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-retention": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-spill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -152,10 +152,9 @@ function completeStdout(toolName: string, result: BashRunResult, rawOutputMaxByt
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
* `SEARCH_RAW_OUTPUT_OVERFLOW`). A `run()` REJECTION — the seam's
* infrastructure failures (pre-aborted signal, unusable workdir, missing
* shell) — is translated into the same taxonomy: a pre-aborted signal becomes
* `SEARCH_ABORTED`, everything else `SEARCH_FAILED`, with the original as
* `cause`.
* infrastructure failures becomes `SEARCH_ABORTED` when the forwarded signal
* aborted, propagates an existing structured {@link HarnessError} unchanged,
* and wraps only untyped spawn/workdir/shell-start errors as `SEARCH_FAILED`.
*
* @param ctx - the plugin context; execution uses its `bash` service.
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
@@ -182,12 +181,13 @@ export async function runRipgrep(
try {
result = await ctx.bash.run(spec)
} catch (error: unknown) {
// The seam contract: run() REJECTS only for infrastructure failures — a
// pre-aborted signal, an unusable workdir, a missing shell. Translate them
// so these failures stay machine-routable under the SEARCH_* taxonomy.
// Abort owns the outcome even when the executor rejects during teardown.
if (spec.signal?.aborted === true) {
throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, 'SEARCH_ABORTED', { cause: error })
}
// Infrastructure implementations may already provide a stable harness
// error (notably SANDBOX_UNAVAILABLE); preserve that owning taxonomy.
if (error instanceof HarnessError) throw error
throw new SearchError(`${toolName} could not start its search command (unusable working directory or missing shell)`, 'SEARCH_FAILED', { cause: error })
}
if (result.aborted) {

View File

@@ -18,6 +18,10 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { SANDBOX_UNAVAILABLE } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
@@ -68,6 +72,7 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
})
afterEach(async () => {
await ctx.fiber.dispose()
await rm(dir, { recursive: true, force: true })
})
@@ -192,4 +197,59 @@ describe.skipIf(!hasRg)('search tools over the real bash executor + real rg', ()
expect(text(result)).toContain('could not start')
})
})
it('preserves rg semantics through partial Landlock and propagates a real structured sandbox failure', async () => {
await ctx.fiber.dispose()
const launcher = join(dir, 'landlock-run')
const failMarker = join(dir, 'fail-runner')
await writeFile(launcher, `#!/bin/sh
while [ "$#" -gt 0 ]; do
case "$1" in
--ro|--rw) shift 2 ;;
--) shift; break ;;
*) printf '%s\\n' 'landlock-run: usage error: unexpected fake argument' >&2; exit 125 ;;
esac
done
printf '%s\\n' 'landlock-run: partial enforcement (older Landlock ABI)' >&2
if [ -e ${ToolFsSearch.singleQuote(failMarker)} ]; then
printf '%s\\n' 'landlock-run: landlock ruleset error: fixture failure' >&2
exit 125
fi
exec "$@"
`, { mode: 0o755 })
ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = {
platform: 'linux',
probeBwrap: () => false,
probeLandlock: () => 'partial',
landlockLauncher: launcher,
}
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: dir })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(SandboxBashExecutor, { cwd: dir, timeoutMs: 20_000 })
await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true })
const grepNoMatch = await call('grep', { pattern: 'does-not-exist' })
expect(grepNoMatch.isError).toBe(false)
expect(text(grepNoMatch)).toBe('No matches found')
const globNoFiles = await call('glob', { pattern: '*.does-not-exist' })
expect(globNoFiles.isError).toBe(false)
expect(text(globNoFiles)).toBe('No files found')
const invalidRegex = await call('grep', { pattern: '(unclosed' })
expect(invalidRegex.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_INVALID_PATTERN' } })
await writeFile(failMarker, '')
const sandboxFailure = await call('grep', { pattern: 'alpha' })
expect(sandboxFailure.error).toMatchObject({
info: { name: 'SandboxUnavailableError', code: SANDBOX_UNAVAILABLE },
})
expect(text(sandboxFailure)).toContain('Runner failure: landlock-run: landlock ruleset error: fixture failure')
expect(text(sandboxFailure)).not.toContain('could not start its search command')
})
})

View File

@@ -13,7 +13,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join, sep } from 'node:path'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import { CallId, HarnessError, createUserMessage } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionToken } from '@deepseek-ai/dsh-tools'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
@@ -373,7 +373,7 @@ describe('workdir derivation and signal forwarding', () => {
const controller = new AbortController()
bash.handler = () => {
controller.abort('cancel search')
throw new Error('executor stopped on abort')
throw new HarnessError('structured executor failure during abort', 'SANDBOX_UNAVAILABLE')
}
const result = await call(ctx, 'grep', { pattern: 'x' }, { signal: controller.signal })
@@ -403,6 +403,26 @@ describe('workdir derivation and signal forwarding', () => {
expect(result.error).toMatchObject({ info: { name: 'SearchError', code: 'SEARCH_FAILED' } })
expect(text(result)).toContain('could not start')
})
it('propagates an existing structured bash failure unchanged', async () => {
const { ctx, bash } = await setup()
const upstream = new HarnessError('sandbox runner failed', 'SANDBOX_UNAVAILABLE')
bash.handler = () => { throw upstream }
const rejection = ToolFsSearch.runRipgrep(
ctx,
{ signal: testToolSignal } as never,
'grep',
"rg --json --regexp='x'",
20_000_000,
)
await expect(rejection).rejects.toBe(upstream)
const result = await call(ctx, 'grep', { pattern: 'x' })
expect(result.error).toMatchObject({ info: { name: 'HarnessError', code: 'SANDBOX_UNAVAILABLE' } })
expect(text(result)).toContain('sandbox runner failed')
expect(text(result)).not.toContain('could not start')
})
})
describe('exit semantics and failure classification', () => {

View File

@@ -16,7 +16,7 @@ import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
class EmptySandbox extends SandboxProvider {
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}
@@ -25,7 +25,7 @@ class RecordingSandbox extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}

View File

@@ -26,7 +26,7 @@ class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
this.calls.push({ argv, policy })
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}

View File

@@ -31,7 +31,7 @@ afterEach(async () => {
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}

View File

@@ -31,7 +31,7 @@ afterEach(async () => {
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md
README.md: 923d983be8c2ccd60ed6eabcf9212dd89ef9bce3
README.zh.md: 5c02b0ce3fac1e39a16f8fc32517856996c30e23
README.md: d53f138a9a752a6ea62992f269bf76680a24d49f
README.zh.md: b0f599af04f101712029585677b7a25fc11e3908

View File

@@ -6,9 +6,9 @@ Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and ca
The package root exports the default and named `LocalSandboxProvider` plugin, `Config`, and its public test-injection seam; platform profile builders stay internal.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries runner-failure signatures so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
Unsupported platforms and unusable runners fail closed with `SANDBOX_UNAVAILABLE`; execution never silently falls through unconfined. Each wrap carries structured runner-failure rules so consumers can distinguish a broken sandbox from a command failure. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection rationale and profile differences.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial and runner-failure signatures. `runnerCommand` is an operator assertion of a bwrap-shaped runner and skips probes, but missing or unexecutable commands still fail closed at execution. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
Policy is per call; the provider stores only the mechanism and cached runner verdict. Each wrap reports enforcement completeness plus backend-specific denial signatures and runner-failure rules. Landlock requires exit 125 and a `landlock-run:` fatal line after excluding only the exact partial-enforcement notice; a notice with child exit 1, 2, or 125 remains a child outcome. Separate outer-shell rules use the resolved/configured argv0 with missing or unexecutable wording and exit 126/127. `runnerCommand` remains the operator-facing config key for a custom runner's fatal signatures and skips probes. Because its mechanism is unknown, it carries both Linux denial dialects. `probeTimeoutMs` bounds functional probes. The [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) owns selection and failure semantics.
The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes.

View File

@@ -6,9 +6,9 @@
包(package)根目录导出默认及命名的 `LocalSandboxProvider` 插件、`Config` 和公共测试注入 seam;平台 profile builder 仍为内部实现。
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带 runner 失败签名,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。
策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝和 runner 失败签名。`runnerCommand` 是操作方对 bwrap 形式 runner 的断言,会跳过探测;但命令缺失或不可执行时,执行仍会被拒绝。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。
策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。独立的外层 shell 规则使用解析后或配置的 argv0、缺失或不可执行诊断文本,以及退出码 126/127。`runnerCommand` 仍是面向运维人员的配置键,用于指定自定义 runner 的致命签名,并会跳过探测。由于其机制未知,它会同时携带两种 Linux 拒绝方言。`probeTimeoutMs` 限制功能探测。[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择与失败语义。
Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。

View File

@@ -1,18 +1,23 @@
/**
* Local sandbox backend. It selects the platform runner chain (Linux bwrap then
* Landlock; macOS Seatbelt), functionally probes competing candidates once, and
* reports each wrap's enforcement and stderr dialects. Missing or unusable
* reports each wrap's enforcement and stderr classification facts. Missing or unusable
* confinement fails closed rather than returning the original argv.
* @module @deepseek-ai/dsh-sandbox-local
*/
import { spawnSync } from 'node:child_process'
import { LAUNCHER_BIN, launcherPath as landlockLauncherPath, probe as defaultProbeLandlock } from 'node-addon-landlock-run'
import {
LAUNCHER_BIN,
LAUNCHER_FAILURE_EXIT,
launcherPath as landlockLauncherPath,
probe as defaultProbeLandlock,
} from 'node-addon-landlock-run'
import { Context } from 'cordis'
import z from 'schemastery'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts'
/** Plugin config. All optional — `static Config` supplies the defaults. */
@@ -142,15 +147,36 @@ const DENIAL_SIGNATURES = {
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
/**
* Runner-owned stderr prefixes cover both internal refusal and shell-level
* not-found errors. Consumers match these before denial text because the
* command never ran on this path.
* Runner-owned fatal diagnostics. Landlock's launcher contract reserves exit
* 125 for launcher failure and emits the partial-ABI notice before successful
* child execution, so both the status gate and the exact exclusion are needed.
*/
const RUNNER_FAILURE_SIGNATURES = {
bwrap: ['bwrap: '],
landlock: [`${LAUNCHER_BIN}: `],
seatbelt: ['sandbox-exec: '],
} as const satisfies Record<SelectedRunner['runner'], readonly string[]>
const RUNNER_FAILURE_RULES = {
bwrap: [{ fatalSignatures: ['bwrap: '] }],
landlock: [{
allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
fatalSignatures: [`${LAUNCHER_BIN}: `],
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
}],
seatbelt: [{ fatalSignatures: ['sandbox-exec: '] }],
} as const satisfies Record<SelectedRunner['runner'], readonly RunnerFailureRule[]>
/**
* Failure shapes emitted by the outer `bash -c 'exec ...'` before the runner
* starts. Shells vary between 126 and 127 for a missing path containing `/`,
* but keep the configured/resolved argv0 and missing/unexecutable wording.
*/
function outerShellFailureRules(argv0: string): readonly RunnerFailureRule[] {
return [{
allowedExitCodes: [126, 127],
fatalSignatures: [
`exec: ${argv0}: not found`,
`${argv0}: No such file or directory`,
`${argv0}: Permission denied`,
`exec: ${argv0}: cannot execute`,
],
}]
}
/**
* Local process-sandbox provider. Registers as `ctx.sandbox`. Stateless
@@ -204,7 +230,7 @@ export class LocalSandboxProvider extends SandboxProvider {
* @param argv - the exact argv the caller is about to spawn.
* @param policy - the file-effect policy this execution runs under.
* @returns the wrapped argv plus the selected backend's enforcement completeness, denial
* signatures, and runner-failure signatures; throws the fail-closed
* signatures, and structured runner-failure rules; throws the fail-closed
* `SANDBOX_UNAVAILABLE` error when the platform has no usable runner.
*/
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
@@ -214,23 +240,25 @@ export class LocalSandboxProvider extends SandboxProvider {
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
enforcement: 'full',
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
// The operator names the configured runner's own pre-exec refusal dialect; the consumer
// additionally re-joins the wrap through an outer `bash -c 'exec …'`, so we can add the
// missing/unexecutable outer-shell shapes ourselves.
runnerFailureSignatures: [
...this.configuredRunnerFailureSignatures,
`exec: ${argv0}: not found`,
`${argv0}: No such file or directory`,
`${argv0}: Permission denied`,
// Preserve the operator-facing signature config as one internal rule;
// outer-shell launch failures remain a separate, argv0-scoped rule.
runnerFailureRules: [
...outerShellFailureRules(argv0),
{ fatalSignatures: this.configuredRunnerFailureSignatures },
],
}
}
const selected = this.selectRunner(policy.mode)
const runnerArgv = this.runnerArgv(selected.runner, policy)
const argv0 = runnerArgv[0] as string
return {
argv: [...this.runnerArgv(selected.runner, policy), '--', ...argv],
argv: [...runnerArgv, '--', ...argv],
enforcement: selected.enforcement,
denialSignatures: DENIAL_SIGNATURES[selected.runner],
runnerFailureSignatures: RUNNER_FAILURE_SIGNATURES[selected.runner],
runnerFailureRules: [
...outerShellFailureRules(argv0),
...RUNNER_FAILURE_RULES[selected.runner],
],
}
}

View File

@@ -23,6 +23,19 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s
const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' }
const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' }
/** Expected argv0-scoped rule for failures in bash's outer `exec`. */
function outerShellRule(argv0: string) {
return {
allowedExitCodes: [126, 127],
fatalSignatures: [
`exec: ${argv0}: not found`,
`${argv0}: No such file or directory`,
`${argv0}: Permission denied`,
`exec: ${argv0}: cannot execute`,
],
}
}
async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) {
const ctx = new Context()
await ctx.plugin(LocalSandboxProvider, config)
@@ -115,11 +128,9 @@ describe('runnerCommand config', () => {
// wrap through an outer `bash -c 'exec …'` — a missing or
// unexecutable runner fails with the OUTER shell's argv0-scoped
// shapes, and those classify as sandbox failures like any rung.
runnerFailureSignatures: [
'fake-runner: profile rejected',
'exec: fake-runner: not found',
'fake-runner: No such file or directory',
'fake-runner: Permission denied',
runnerFailureRules: [
outerShellRule('fake-runner'),
{ fatalSignatures: ['fake-runner: profile rejected'] },
],
})
expect(probeBwrap).not.toHaveBeenCalled()
@@ -163,7 +174,10 @@ describe('the platform chains', () => {
argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'],
enforcement: 'full',
denialSignatures: ['read-only file system'],
runnerFailureSignatures: ['bwrap: '],
runnerFailureRules: [
outerShellRule('bwrap'),
{ fatalSignatures: ['bwrap: '] },
],
})
expect(probeLandlock).not.toHaveBeenCalled()
})
@@ -178,14 +192,21 @@ describe('the platform chains', () => {
argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
enforcement: 'full',
denialSignatures: ['permission denied'],
runnerFailureSignatures: ['landlock-run: '],
runnerFailureRules: [
outerShellRule(launcher),
{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
},
],
})
expect(probeLandlock).toHaveBeenCalledWith(launcher)
})
it('darwin selects its sole candidate WITHOUT probing: nothing to arbitrate', async () => {
// The safety property moves to execution time: an unusable sandbox-exec
// refuses to run the command, and the wrap's runnerFailureSignatures let
// refuses to run the command, and the wrap's runnerFailureRules let
// the consumer classify that as a sandbox failure, not a task failure.
const probeSeatbelt = vi.fn(() => true)
const { sandbox } = await setup({}, { platform: 'darwin', probeSeatbelt })
@@ -194,7 +215,10 @@ describe('the platform chains', () => {
argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'],
enforcement: 'full',
denialSignatures: ['operation not permitted'],
runnerFailureSignatures: ['sandbox-exec: '],
runnerFailureRules: [
outerShellRule('sandbox-exec'),
{ fatalSignatures: ['sandbox-exec: '] },
],
})
expect(probeSeatbelt).not.toHaveBeenCalled()
})
@@ -360,7 +384,10 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'],
enforcement: 'full',
denialSignatures: ['operation not permitted'],
runnerFailureSignatures: ['sandbox-exec: '],
runnerFailureRules: [
outerShellRule(exec),
{ fatalSignatures: ['sandbox-exec: '] },
],
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox/README.md
README.md: 99f0641560937f66df6db76ae55c90595329792f
README.zh.md: 5d7dfb303ea2405e725d162698c3ab3d39485636
README.md: 1b522b2c72d00bfed89650aa7f22b65a72d26085
README.zh.md: 310b6bd5f526678e61d72846f154e7226495fca6

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Abstract process-sandbox seam. Owns the `ctx.sandbox` service contract ([`SandboxProvider`](src/index.ts)) and the confinement vocabulary the harness shares: `SandboxMode` (`read-only` / `workspace-write` / `danger-full-access`, file effects only), `SandboxEnforcement` (`full` / `partial`, per kernel ABI), `SandboxExecutionPolicy` (the complete per-call mode + workspace root), `SandboxPolicy` (its confined subset), and the fail-closed `SANDBOX_UNAVAILABLE` error. Interface package of the [capability-seam split](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): depends only on cordis (+ the harness error base), never on a backend.
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus two facts about the selected backend: the enforcement completeness it achieves and its denial dialect (`denialSignatures`, the stderr substrings its kernel prints on a denied file effect — what stderr-inferring consumers match instead of a cross-backend union); when no backend is usable it throws rather than passing the argv through unconfined.
The contract in one line: `ctx.sandbox.confine(argv, policy)` returns the argv to spawn INSTEAD of your own — wrapped so the process (and everything it spawns) runs confined — plus the selected backend's enforcement completeness, denial dialect (`denialSignatures`), and structured runner-failure evidence (`runnerFailureRules`); when no backend is usable it throws rather than passing the argv through unconfined. The [core type catalog](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects) owns the exact classifier shape.
Policy rides the call, not the provider: two consumers may confine under different policies at the same instant (bash under `read-only` while a confined child agent keeps its state directory writable), and an approved escalated retry is just a new call with a wider policy.
@@ -39,4 +39,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **File effects are the whole policy vocabulary** — the seam expresses no network, process, syscall, device, or credential restrictions.
- **Same-world confinement only** — containers, microVMs, and remote execution require replacing capability implementations rather than adding a provider here.
- **Denial reporting is a stderr dialect** — the seam returns backend signatures instead of a typed runtime denial channel, so consumers that need classification must infer it from the child process's output.
- **Runner diagnostics are in-band** — exit status plus stderr evidence cannot prove which process wrote a matching line, so a confined child that deliberately mimics its runner can cause an availability/diagnostic false attribution. This cannot bypass confinement; an out-of-band runner-status channel is deferred.
- **One provider per context** — composing different sandbox mechanisms simultaneously requires a provider-level ladder or separate Cordis contexts; callers choose policy per call, not backend identity.

View File

@@ -4,7 +4,7 @@
抽象进程沙箱 seam。负责定义 `ctx.sandbox` 服务契约([`SandboxProvider`](src/index.ts))与 harness 共享的限制词汇:`SandboxMode`(`read-only`/`workspace-write`/`danger-full-access`,仅限文件操作)、`SandboxEnforcement`(`full`/`partial`,针对每种内核 ABI)、`SandboxExecutionPolicy`(每次调用的完整模式及工作区根目录)、`SandboxPolicy`(其中受限制的子集),以及故障时拒绝放行的 `SANDBOX_UNAVAILABLE` 错误。它是[能力 seam 拆分](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)的接口包(package):只依赖 cordis(及 harness 错误基类),绝不依赖后端。
用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;另附所选后端的两个事实:它达到的强制执行完整度,以及拒绝方言(`denialSignatures`,即内核在文件操作被拒绝时打印到 stderr 的子字符串;通过 stderr 推断的消费方会匹配这些字符串,而不是统一的跨后端签名集合)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。
用一句话概括契约:`ctx.sandbox.confine(argv, policy)` 返回用于 spawn、应当取代调用方原始 argv 的 argv。返回值经过包装,使进程及其派生的所有进程都在限制下运行;还会附带所选后端达到的强制执行完整度、拒绝方言(`denialSignatures`)和结构化 runner 失败证据(`runnerFailureRules`)。没有可用后端时,它会抛出异常,绝不会原样传递 argv 使其不受限制地运行。[核心类型目录](../../../docs/core-data-structures/sandbox.md#wrapped-argv-and-classification-dialects)负责定义分类器的精确结构。
策略随调用传递,而不属于提供方:两个消费方可以同时按不同策略施加限制(bash 使用 `read-only`,而受限制的子 agent(智能体)保持其状态目录可写);获批的升权重试只是使用更宽策略发起的新调用。
@@ -39,4 +39,5 @@ sandbox mode "<mode>" is requested but no sandbox backend is usable on this host
- **文件操作是完整的策略词汇**:该 seam 不表达网络、进程、系统调用、设备或凭据限制。
- **只支持与宿主共享文件系统和内核的限制**:容器、microVM 与远程执行需要替换能力实现,而不是在此处增加提供方。
- **拒绝报告是一种 stderr 方言**:该 seam 返回后端签名,而非类型化运行时拒绝通道,因此需要分类的消费方必须从子进程输出推断。
- **Runner 诊断使用带内通道**:退出状态与 stderr 证据无法证明匹配行由哪个进程写入,因此受限子进程若故意模仿 runner,就可能造成可用性或诊断误归因。这无法绕过约束;带外 runner 状态通道暂缓实现。
- **每个上下文只有一个提供方**:同时组合不同沙箱机制需要提供方级阶梯或独立 Cordis 上下文;调用方逐调用选择策略,而非后端标识。

View File

@@ -62,6 +62,22 @@ export interface SandboxPolicy extends SandboxExecutionPolicy {
mode: ConfinedSandboxMode
}
/**
* Evidence that identifies a sandbox runner failing before it executes the
* wrapped command. A consumer first applies {@link allowedExitCodes} when
* present, removes {@link informationalLines} by case-insensitive exact line
* equality, then matches {@link fatalSignatures} case-insensitively within
* each remaining stderr line. Exit status alone never proves runner failure.
*/
export interface RunnerFailureRule {
/** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */
allowedExitCodes?: readonly number[]
/** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */
fatalSignatures: readonly string[]
/** Benign stderr lines excluded by exact full-line equality before fatal matching. */
informationalLines?: readonly string[]
}
/**
* A {@link SandboxProvider.confine} result: the argv to spawn in place of
* the caller's own, plus the enforcement completeness the selected backend
@@ -82,11 +98,12 @@ export interface ConfinedArgv {
*/
denialSignatures: readonly string[]
/**
* Case-insensitive signatures for runner failure before command execution.
* Consumers check these before denial signatures: runner failure means the
* Structured runner-failure evidence rules. Consumers require a matching
* fatal stderr line (after informational exclusions) and any rule-specific
* exit-code gate before checking denial signatures: runner failure means the
* command never ran, while denial means confinement worked and blocked it.
*/
runnerFailureSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
}
/**