fix(sandbox): require runner-specific spawn evidence

This commit is contained in:
Hypatia May
2026-08-04 14:47:46 +08:00
parent 0cd1611023
commit 96ba98c99e
14 changed files with 217 additions and 39 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: c1a6d6ae19f6d5ec95087d32c7d5d18d5f28b7f5
README.zh.md: a6bcc812e35c842c7d3880a41814a872f3dbca14
README.md: d1f2554776d2e9b3a2a5fc1742c12ec69a5d4a97
README.zh.md: 3aab06145c00e9df4bd06628129cf043769ffb49

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.** A rejected spawn of the provider argv is out-of-band proof that the confined launch never started: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. After a process starts, 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`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **Runner failures are sandbox failures, never command failures.** Before a process starts, a spawn rejection is attributed to the runner only when its structured code and an independent lookup both show that the provider's executable is missing or unusable: foreground execution throws `SANDBOX_UNAVAILABLE` with the original spawn detail, while background settlement stamps `runnerFailed: true` and `denied: false`. Other spawn rejections retain the local executor's command-start failure semantics. After a process starts, 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`. Confined background handles retain their mode/enforcement facts and release per-process accounting in either path.
- **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). A provider-argv spawn rejection supplies the original spawn error as detail; a settled runner failure supplies the matched fatal stderr line and preserves the original stderr collection.
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). A provider-executable spawn failure supplies the original spawn error as detail; a rejection without runner-specific evidence remains an ordinary command-start error. A settled runner failure supplies the matched fatal stderr line 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 失败是沙箱失败,绝不是命令失败。** spawn 提供方 argv 遭拒,是受限启动从未开始的带外证据:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true``denied: false`。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **Runner 失败是沙箱失败,绝不是命令失败。** 进程启动前,只有当 spawn 拒绝的结构化错误码与一次独立查找均表明提供方可执行文件缺失或不可用时,才会将该拒绝归因于 runner:前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带原始 spawn 错误详情,后台结算则会标记 `runnerFailed: true``denied: false`其他 spawn 拒绝保留本地执行器的命令启动失败语义。进程启动后,先按整行精确匹配排除信息性行,随后规则的可选退出码门控和余下 stderr 中的一行致命诊断必须同时匹配。匹配结果优先于拒绝;前台执行会抛出 `SANDBOX_UNAVAILABLE` 并附带匹配到的致命行,已结算的后台进程则会标记 `process.sandbox.runnerFailed`Bash 结果生成方通过通用 `task_output` 渲染它。无论走哪条路径,受限制的后台句柄都会保留自身的模式/强制执行事实,并释放每进程计数。
- **部署回退,每次调用策略。** [`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` 定义。spawn 提供方 argv 遭拒时,以原始 spawn 错误作为详细信息;已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
如果没有 runner 能强制执行受限模式,前台调用会传播 [`SANDBOX_UNAVAILABLE` 错误](../../sandbox/sandbox/README.md#confinement-error-indirectly);该错误由 `dsh-sandbox` 定义。提供方可执行文件的 spawn 失败会以原始 spawn 错误作为详细信息;没有 runner 特定证据的拒绝仍是普通的命令启动错误。已结算的 runner 失败则以匹配到的致命 stderr 行作为详细信息,并保留原始 stderr 收集结果。
#### Token 影响

View File

@@ -4,9 +4,56 @@
* @module @deepseek-ai/dsh-bash-sandbox/helpers
*/
import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, resolve } from 'node:path'
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
import type { RunnerFailureRule } from '@deepseek-ai/dsh-sandbox'
/** Spawn codes that can describe an unavailable executable. */
const EXECUTABLE_SPAWN_CODES = new Set(['EACCES', 'ENOENT', 'ENOEXEC', 'ENOTDIR', 'EPERM'])
/** Whether one resolved path is a regular executable file. */
function isExecutableFile(path: string): boolean {
try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}
/**
* Require positive runner evidence instead of treating every spawn rejection
* as sandbox-owned. Node uses the same ENOENT/EACCES channel for unrelated
* launch failures, so the provider executable must also be absent or unusable.
* @param error - the original spawn rejection.
* @param runnerProgram - provider argv[0], the executable that establishes confinement.
* @param workdir - the spawn cwd, used to resolve relative executable paths.
* @param searchPath - the spawn environment's PATH value.
* @returns whether the rejection has executable-specific runner evidence.
*/
export function isRunnerSpawnFailure(
error: unknown,
runnerProgram: string | undefined,
workdir: string,
searchPath: string | undefined,
): boolean {
if (typeof error !== 'object' || error === null) return false
const code = (error as { code?: unknown }).code
if (typeof code !== 'string' || !EXECUTABLE_SPAWN_CODES.has(code) || runnerProgram === undefined) return false
const isPath = runnerProgram.includes('/') || runnerProgram.includes('\\')
const pathEntries = isPath ? [''] : searchPath?.split(delimiter) ?? []
if (pathEntries.length === 0) return false
return pathEntries.every((entry) => {
const candidate = isPath
? resolve(workdir, runnerProgram)
: resolve(workdir, entry.length > 0 ? entry : '.', runnerProgram)
return !isExecutableFile(candidate)
})
}
/** Fatal runner evidence retained for infrastructure-error detail. */
interface RunnerFailureMatch {
/** The original stderr line that matched a fatal signature. */
@@ -43,11 +90,10 @@ export function classifyRunnerFailure(
for (const rule of rules) {
if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))
// An empty substring matches every string in JavaScript. Ignore it so a
// malformed public rule cannot turn a gated exit status into evidence by
// itself; keep any valid signatures beside it active.
// An empty or whitespace-only substring is not meaningful runner evidence.
// Ignore it while keeping any valid signatures beside it active.
const fatalSignatures = rule.fatalSignatures
.filter(signature => signature.length > 0)
.filter(signature => signature.trim().length > 0)
.map(signature => signature.toLowerCase())
for (const line of lines) {
const lowered = line.toLowerCase()

View File

@@ -1,10 +1,10 @@
/**
* Sandbox-consuming bash executor. It wraps the exact local bash argv through
* `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
* processes carry `runnerFailed`. The tool owns approval and passes a complete
* per-call policy.
* mode, enforcement, and denial facts. Positive runner-launch evidence means
* the command never ran: foreground calls throw `SANDBOX_UNAVAILABLE`, while
* background processes carry `runnerFailed`; other spawn rejections retain
* local-executor semantics. The tool owns approval and passes a complete per-call policy.
* @module @deepseek-ai/dsh-bash-sandbox
*/
@@ -23,7 +23,7 @@ import type {
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'
import { classifyDenial, classifyRunnerFailure, matchesSignature } from './helpers.ts'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure, matchesSignature } from './helpers.ts'
/**
* Plugin config: the local executor's knobs, verbatim. The sandbox policy —
@@ -60,6 +60,9 @@ export class SandboxBashExecutor extends LocalBashExecutor {
enforcement: SandboxEnforcement
denialSignatures: readonly string[]
runnerFailureRules: readonly RunnerFailureRule[]
runnerProgram: string | undefined
searchPath: string | undefined
workdir: string
}>()
constructor(ctx: Context, config: Config) {
@@ -97,7 +100,10 @@ export class SandboxBashExecutor extends LocalBashExecutor {
} catch (error) {
// An upstream abort remains cancellation even when it prevents spawn.
if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
throw new SandboxUnavailableError(mode, String(error))
if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir, spec.env?.PATH ?? process.env.PATH)) {
throw new SandboxUnavailableError(mode, String(error))
}
throw error
}
// Runner failure outranks denial because the command did not run. Carry
// the matched fatal line, not an informational line that preceded it.
@@ -116,7 +122,15 @@ export class SandboxBashExecutor extends LocalBashExecutor {
const confined = this.confine(spec.command, { ...policy, mode })
const proc = this.startArgv(spec, confined.argv)
const { enforcement, denialSignatures, runnerFailureRules } = confined
this.processFacts.set(proc, { mode, enforcement, denialSignatures, runnerFailureRules })
this.processFacts.set(proc, {
mode,
enforcement,
denialSignatures,
runnerFailureRules,
runnerProgram: confined.argv[0],
searchPath: spec.env?.PATH ?? process.env.PATH,
workdir: spec.workdir,
})
return proc
}
@@ -131,7 +145,8 @@ export class SandboxBashExecutor extends LocalBashExecutor {
// A rejected spawn never started the confined launch. Otherwise runner
// failure outranks denial because its diagnostics may contain denial terms.
const runnerFailed = spawnFailed
|| classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
? isRunnerSpawnFailure(spawnError, facts.runnerProgram, facts.workdir, facts.searchPath)
: classifyRunnerFailure(proc.exitCode, stderr, facts.runnerFailureRules) !== undefined
proc.sandbox = {
mode: facts.mode,
denied: !runnerFailed && matchesSignature(proc.exitCode, stderr, facts.denialSignatures),

View File

@@ -17,7 +17,7 @@ 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 type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess'
import { classifyDenial, classifyRunnerFailure } from '../src/helpers.ts'
import { classifyDenial, classifyRunnerFailure, isRunnerSpawnFailure } from '../src/helpers.ts'
import type { Config } from '@deepseek-ai/dsh-bash-sandbox'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-'))
@@ -172,6 +172,22 @@ describe('fail closed', () => {
controller.abort(reason)
await expect(bash.run(bash.resolve({ command: 'true', signal: controller.signal }))).rejects.toBe(reason)
})
it.each(['read-only', 'danger-full-access'] as const)(
'keeps an invalid workdir as an ordinary foreground spawn failure in %s mode',
async (mode) => {
const { bash } = await setup({ mode })
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
try {
const failure = await bash.run(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
.catch((error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
expect(failure).not.toBeInstanceOf(SandboxUnavailableError)
} finally {
rmSync(parent, { recursive: true, force: true })
}
},
)
})
describe('danger-full-access', () => {
@@ -273,10 +289,26 @@ describe('classifyDenial', () => {
})
})
describe('isRunnerSpawnFailure', () => {
it('requires both an executable-class error and an independently unavailable provider executable', () => {
const missingRunner = join(spillDir, 'definitely-missing-runner')
const enoent = Object.assign(new Error('spawn failed'), { code: 'ENOENT' })
const emfile = Object.assign(new Error('spawn failed'), { code: 'EMFILE' })
expect(isRunnerSpawnFailure(enoent, missingRunner, process.cwd(), process.env.PATH)).toBe(true)
expect(isRunnerSpawnFailure(enoent, process.execPath, process.cwd(), process.env.PATH)).toBe(false)
expect(isRunnerSpawnFailure(enoent, spillDir, process.cwd(), process.env.PATH)).toBe(true)
expect(isRunnerSpawnFailure(emfile, missingRunner, process.cwd(), process.env.PATH)).toBe(false)
expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, '')).toBe(true)
expect(isRunnerSpawnFailure(enoent, 'definitely-missing-runner', spillDir, undefined)).toBe(false)
expect(isRunnerSpawnFailure(enoent, undefined, spillDir, process.env.PATH)).toBe(false)
})
})
describe('classifyRunnerFailure', () => {
it('ignores empty fatal signatures instead of treating exit status or notice text as evidence', () => {
it('ignores empty and whitespace-only fatal signatures instead of treating exit status or notice text as evidence', () => {
const notice = 'landlock-run: partial enforcement (older Landlock ABI)'
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: [''] }]
const emptyRule = [{ allowedExitCodes: [125], fatalSignatures: ['', ' ', '\t'] }]
expect(classifyRunnerFailure(125, '', emptyRule)).toBeUndefined()
expect(classifyRunnerFailure(125, notice, emptyRule)).toBeUndefined()
})
@@ -286,7 +318,7 @@ describe('classifyRunnerFailure', () => {
const fatal = 'landlock-run: ruleset creation failed'
const rules = [{
allowedExitCodes: [125],
fatalSignatures: ['', 'landlock-run: '],
fatalSignatures: ['', ' ', 'landlock-run: '],
informationalLines: [notice],
}]
expect(classifyRunnerFailure(125, `${notice}\nchild diagnostic\n${fatal}`, rules)).toEqual({ detail: fatal })
@@ -354,10 +386,10 @@ describe('result facts', () => {
})
describe('background sandbox facts', () => {
it('stamps facts and releases accounting when background spawn fails', async () => {
it('keeps an invalid-workdir spawn rejection ordinary and releases accounting', 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 }))
const parent = mkdtempSync(join(tmpdir(), 'dsh-sandbox-missing-cwd-'))
const task = bash.start(bash.resolve({ command: 'true', workdir: join(parent, 'missing') }))
await task.done
@@ -367,13 +399,13 @@ describe('background sandbox facts', () => {
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
const accounting = (bash as unknown as { processFacts: Map<unknown, unknown> }).processFacts
expect(accounting.size).toBe(0)
rmSync(parent, { recursive: true, force: true })
})
it('classifies a spawn rejection whose reason is undefined', async () => {
it('does not invent runner evidence when a spawn rejection has no structured reason', async () => {
const { ctx, bash } = await setup()
const emptyReader: SubprocessOutputReader = {
readFrom: () => ({ text: '', nextOffset: 0, lossy: false }),
@@ -399,7 +431,6 @@ describe('background sandbox facts', () => {
mode: 'read-only',
denied: false,
enforcement: 'full',
runnerFailed: true,
})
})

View File

@@ -16,10 +16,10 @@ import type { BashProcess } from '@deepseek-ai/dsh-bash'
*/
export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } {
// TODO(background-infrastructure-outcome): widen BashProcess with an explicit
// infrastructure-failure outcome, then map spawn failures and
// sandbox.runnerFailed to task `failed`. The current seam aliases a spawn
// failure with a signal-less kill and a runner failure with an ordinary
// wrapper exit; real nonzero command exits must remain `completed`.
// infrastructure-failure outcome, then map it to task `failed`. Restricted
// runner failures expose sandbox.runnerFailed, but unconfined spawn failures
// still alias a signal-less kill; real nonzero command exits must remain
// `completed`.
if (proc.status === 'killed') {
return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' }
}