fix(sandbox): spawn confined argv directly (round 1)

This commit is contained in:
Hypatia May
2026-08-04 12:04:48 +08:00
parent 2eedb54849
commit e36d040d0a
34 changed files with 392 additions and 282 deletions
@@ -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: d53f138a9a752a6ea62992f269bf76680a24d49f
README.zh.md: b0f599af04f101712029585677b7a25fc11e3908
README.md: f7dd5619fa69ae226def78415a91d51ba99e0a65
README.zh.md: 9477e8b99df54f96c364413ebbf8f99cfff4b2f9
+2 -2
View File
@@ -8,7 +8,7 @@ The package root exports the default and named `LocalSandboxProvider` plugin, `C
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 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.
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. Bubblewrap and Seatbelt remain signature-only because neither public contract reserves a launcher-failure status. Consumers spawn the returned argv directly, so a missing or unexecutable runner is an out-of-band spawn failure while a successfully launched child exit 126 or 127 remains ordinary. `runnerCommand` skips probes and requires one or more non-empty, single-line, case-insensitive `runnerFailureSignatures` entries for the custom runner's own fatal dialect. 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.
@@ -37,4 +37,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly.
- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement.
+2 -2
View File
@@ -8,7 +8,7 @@
不受支持的平台和不可用 runner 会以 `SANDBOX_UNAVAILABLE` 拒绝执行;执行绝不会静默回退为不受限制。每次包装都携带结构化 runner 失败规则,使消费方能够区分损坏的沙箱与命令失败。[沙箱 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)负责说明选择依据与 profile 差异。
策略逐调用传入;提供方只存储机制与缓存的 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)负责说明选择与失败语义。
策略逐调用传入;提供方只存储机制与缓存的 runner 结论。每次包装都会报告强制执行完整度,以及后端专用的拒绝签名和 runner 失败规则。Landlock 只有在退出码为 125,且排除唯一精确匹配的部分强制执行通知后仍存在一行 `landlock-run:` 致命诊断时,才判定 runner 失败;携带该通知的子进程即使以 1、2 或 125 退出,也仍按子进程结果处理。Bubblewrap 和 Seatbelt 仍仅依据签名,因为两者的公开契约均未保留 launcher 失败状态。消费方会直接 spawn 返回的 argv,因此 runner 缺失或不可执行属于带外 spawn 失败,而成功启动的子进程以 126 或 127 退出时仍按普通结果处理。`runnerCommand` 会跳过探测,并要求为自定义 runner 自身的致命方言提供一个或多个非空、单行、不区分大小写的 `runnerFailureSignatures` 条目。由于其机制未知,它会同时携带两种 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 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。
@@ -37,4 +37,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list
- **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
- **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
- **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。
- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile。
- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。
+17 -42
View File
@@ -8,8 +8,9 @@
import { spawnSync } from 'node:child_process'
import {
LAUNCHER_BIN,
LAUNCHER_FATAL_PREFIX,
LAUNCHER_FAILURE_EXIT,
PARTIAL_ENFORCEMENT_NOTICE,
launcherPath as landlockLauncherPath,
probe as defaultProbeLandlock,
} from 'node-addon-landlock-run'
@@ -25,17 +26,17 @@ export interface Config {
/**
* Override the runner argv; bwrap-shaped profile arguments are appended. A
* non-empty override asserts full enforcement and skips built-in selection and
* probing; a broken runner then fails at execution and must be identifiable by
* {@link runnerFailureSignatures}.
* probing. A runner that starts but refuses its profile must be identifiable by
* {@link runnerFailureSignatures}; spawn rejection remains a consumer-owned
* infrastructure failure.
*/
runnerCommand?: string[]
/**
* Case-insensitive stderr substrings emitted when a configured
* {@link runnerCommand} refuses its profile before executing the wrapped
* command. Required and non-empty with `runnerCommand`; rejected without
* it. Missing/unexecutable runner errors are added automatically from
* `runnerCommand[0]`, while these signatures cover an executable runner's
* own failure dialect.
* it. Each entry is a non-empty, single-line, case-insensitive substring
* covering the executable runner's own failure dialect.
*/
runnerFailureSignatures?: string[]
/** Positive timeout for each functional probe; zero would mean unbounded to Node. */
@@ -147,37 +148,21 @@ const DENIAL_SIGNATURES = {
} as const satisfies Record<SelectedRunner['runner'] | 'runnerCommand', readonly string[]>
/**
* 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.
* Runner-owned fatal diagnostics. Landlock has a versioned exit-125 plus
* fatal-line launcher-failure contract. Bubblewrap's current fatal paths exit
* 1 but its public contract does not reserve that status, while sandbox-exec
* publishes no launcher-failure status; those backends remain signature-only.
*/
const RUNNER_FAILURE_RULES = {
bwrap: [{ fatalSignatures: ['bwrap: '] }],
landlock: [{
allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
fatalSignatures: [`${LAUNCHER_BIN}: `],
informationalLines: [`${LAUNCHER_BIN}: partial enforcement (older Landlock ABI)`],
fatalSignatures: [LAUNCHER_FATAL_PREFIX],
informationalLines: [PARTIAL_ENFORCEMENT_NOTICE],
}],
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
* apart from the cached chain verdict — it spawns nothing but the one-time
@@ -213,8 +198,8 @@ export class LocalSandboxProvider extends SandboxProvider {
if (runner.length > 0 && runnerFailureSignatures.length === 0) {
throw new Error('sandbox-local: runnerCommand requires at least one runnerFailureSignatures entry')
}
if (runnerFailureSignatures.some(signature => signature.trim().length === 0)) {
throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty')
if (runnerFailureSignatures.some(signature => signature.trim().length === 0 || /[\r\n]/u.test(signature))) {
throw new Error('sandbox-local: runnerFailureSignatures entries must be non-empty single-line strings')
}
this.runnerCommand = runner.length > 0 ? runner : undefined
this.configuredRunnerFailureSignatures = runnerFailureSignatures
@@ -235,30 +220,20 @@ export class LocalSandboxProvider extends SandboxProvider {
*/
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
if (this.runnerCommand !== undefined) {
const argv0 = this.runnerCommand[0] as string
return {
argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
enforcement: 'full',
denialSignatures: DENIAL_SIGNATURES.runnerCommand,
// 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 },
],
runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
}
}
const selected = this.selectRunner(policy.mode)
const runnerArgv = this.runnerArgv(selected.runner, policy)
const argv0 = runnerArgv[0] as string
return {
argv: [...runnerArgv, '--', ...argv],
enforcement: selected.enforcement,
denialSignatures: DENIAL_SIGNATURES[selected.runner],
runnerFailureRules: [
...outerShellFailureRules(argv0),
...RUNNER_FAILURE_RULES[selected.runner],
],
runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
}
}
@@ -12,6 +12,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import {
LAUNCHER_FAILURE_EXIT,
LAUNCHER_FATAL_PREFIX,
PARTIAL_ENFORCEMENT_NOTICE,
} from 'node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import {
@@ -23,19 +28,6 @@ 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)
@@ -48,7 +40,7 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter
function fakeLauncher(report = 'landlock: fully enforced'): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit 125\n`, { mode: 0o755 })
writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
return launcher
}
@@ -124,14 +116,7 @@ describe('runnerCommand config', () => {
// An operator runner's kernel mechanism is unknown: both Linux
// file-denial dialects, never bare EPERM.
denialSignatures: ['read-only file system', 'permission denied'],
// The runner's own dialect is unknown, but the consumer re-joins the
// 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.
runnerFailureRules: [
outerShellRule('fake-runner'),
{ fatalSignatures: ['fake-runner: profile rejected'] },
],
runnerFailureRules: [{ fatalSignatures: ['fake-runner: profile rejected'] }],
})
expect(probeBwrap).not.toHaveBeenCalled()
expect(probeLandlock).not.toHaveBeenCalled()
@@ -157,11 +142,14 @@ describe('runnerCommand config', () => {
)
})
it('rejects blank configured-runner failure signatures', async () => {
await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [' '] })).rejects.toThrow(
'runnerFailureSignatures entries must be non-empty',
)
})
it.each([' ', 'fatal\ncontinued', 'fatal\rcontinued'])(
'rejects an unusable configured-runner failure signature %j',
async (signature) => {
await expect(setup({ runnerCommand: ['fake-runner'], runnerFailureSignatures: [signature] })).rejects.toThrow(
'runnerFailureSignatures entries must be non-empty single-line strings',
)
},
)
})
describe('the platform chains', () => {
@@ -174,10 +162,7 @@ describe('the platform chains', () => {
argv: ['bwrap', ...bwrapProfileArgs(RO), '--', 'true'],
enforcement: 'full',
denialSignatures: ['read-only file system'],
runnerFailureRules: [
outerShellRule('bwrap'),
{ fatalSignatures: ['bwrap: '] },
],
runnerFailureRules: [{ fatalSignatures: ['bwrap: '] }],
})
expect(probeLandlock).not.toHaveBeenCalled()
})
@@ -192,14 +177,11 @@ describe('the platform chains', () => {
argv: [launcher, ...landlockProfileArgs(WW), '--', 'bash', '-c', 'echo hi'],
enforcement: 'full',
denialSignatures: ['permission denied'],
runnerFailureRules: [
outerShellRule(launcher),
{
allowedExitCodes: [125],
fatalSignatures: ['landlock-run: '],
informationalLines: ['landlock-run: partial enforcement (older Landlock ABI)'],
},
],
runnerFailureRules: [{
allowedExitCodes: [LAUNCHER_FAILURE_EXIT],
fatalSignatures: [LAUNCHER_FATAL_PREFIX],
informationalLines: [PARTIAL_ENFORCEMENT_NOTICE],
}],
})
expect(probeLandlock).toHaveBeenCalledWith(launcher)
})
@@ -215,10 +197,7 @@ describe('the platform chains', () => {
argv: ['sandbox-exec', ...seatbeltProfileArgs(RO), '--', 'bash', '-c', 'echo hi'],
enforcement: 'full',
denialSignatures: ['operation not permitted'],
runnerFailureRules: [
outerShellRule('sandbox-exec'),
{ fatalSignatures: ['sandbox-exec: '] },
],
runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }],
})
expect(probeSeatbelt).not.toHaveBeenCalled()
})
@@ -335,7 +314,7 @@ describe('the default landlock probe (launcher CLI contract)', () => {
it('reads a failing launcher as unusable: the chain ends and fails closed', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-'))
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, '#!/bin/sh\nexit 125\n', { mode: 0o755 })
writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 })
const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
expect(() => sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
@@ -384,10 +363,7 @@ describe('the default seatbelt probe (sandbox-exec contract)', () => {
argv: [exec, ...seatbeltProfileArgs(RO), '--', 'true'],
enforcement: 'full',
denialSignatures: ['operation not permitted'],
runnerFailureRules: [
outerShellRule(exec),
{ fatalSignatures: ['sandbox-exec: '] },
],
runnerFailureRules: [{ fatalSignatures: ['sandbox-exec: '] }],
})
})