第 8 章
沙箱与进程树
场景还原
你在终端里跑一个本地 agent,让它「帮我跑一下这个测试脚本」。脚本中途卡住,工具层 30 秒超时后把命令标记为超时。你打开活动监视器,发现一个 sleep 1000 还在吃 CPU。工具超时杀的是直接子进程,sh -c 派生的后代没人管。
推一步想:agent 执行的命令来自用户输入、模型生成的脚本,或网页上复制来的安装命令,都不是 harness 写的代码。它设了几道防线,每道挡什么、挡不住什么,是这一章的主题。
同样的 Bash 工具请求,在装有 bubblewrap 的 Linux 上、在没有 bwrap 但有 Landlock 内核的 Linux 上、在 macOS 上、在没配远程沙箱的宿主机上,走的路径完全不同。harness 要保证:换一条路径,语义不变,该拒的时候一定拒。
答案分两层。第一层是沙箱 seam:把「命令在什么文件效果下跑」变成每次调用携带的 policy,由 provider 选后端、包 argv。第二层是进程树 seam:spawn 出的命令是一个 detached 进程树,超时杀整棵树。两层由 docs/subsystems/sandbox.md 统一定义,packages/sandbox/ 与 packages/subprocess/ 分别实现,C 写的 Landlock launcher 沉在 native/landlock-run/。
逐行精读
入口:policy 每次调用解析一次
ctx.sandboxPolicy.resolve() 是唯一解析点,bash、fs、终端都读它,沙箱模式每次能力调用时重新解析。
135 resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy {136 const { session } = request137 return {138 mode: request.mode ?? (session === undefined ? undefined : this.overrideOf(session)) ?? this.defaultMode,139 workspaceRoot: resolveWorkspaceRoot(session?.header.cwd ?? this.workspaceRoot),⋯ ...session === undefined ? {} : { sessionId: session.id },141 }142 }优先级从上往下:显式批准的 mode 覆盖 > 会话最近一次 sandbox/mode 事件 > 部署默认值。默认值是 schema 里的 read-only(sandbox-policy/src/index.ts:94),fail-safe 方向:没配置就只读,可写要显式配置。
模式词汇表与 enforcement 事实
解析出的 policy 落到三类模式。SandboxMode 只管文件效果,网络和进程可见性不在词汇表里。
29export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'23Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`.这句话的后果是:provider 的输入类型排除了 danger-full-access,即 ConfinedSandboxMode,约束在类型上写死。
然后是 enforcement 的诚实声明,后端能执行到什么程度是报告出来的事实:
30Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases.要求绝对边界的能力调用方看到 partial,必须自己决定拒绝还是接受,不能当它是 full。
抽象 seam:confine 必须返回 enforcing argv 或 fail closed
SandboxProvider 是抽象类,只有一个方法 confine。它的契约是这一章的核心:
152/**153 * Abstract process-sandbox service. {@link confine} must return enforcing argv154 * or fail closed at wrap or runner-execution time; silent unconfined passthrough155 * is forbidden. Functional probes arbitrate multi-runner chains and may be156 * skipped for a sole candidate, whose own refusal remains the fail-closed end.157 */158export abstract class SandboxProvider extends Service {159 /* v8 ignore next -- abstract service construction is covered through concrete provider packages. */160 constructor(ctx: Context) {161 super(ctx, 'sandbox')162 }163164 /**165 * Wrap `argv` so it executes confined under `policy` on this host; the166 * caller spawns the returned argv in place of its own.167 * @param argv - the exact argv the caller is about to spawn (program plus168 * arguments), NOT a shell string — a shell-shaped consumer passes169 * `['bash', '-c', command]`.170 * @param policy - the file-effect policy this execution runs under,171 * carried per call (see {@link SandboxPolicy}).172 * @returns the argv to spawn instead, plus the enforcement completeness173 * the selected backend achieves for it.174 */175 abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv176}契约三件事:输入是 argv 数组,shell 形状的消费者自己传 ['bash', '-c', command];输出是替换后的 argv 加 enforcement 事实;最关键的一句是 silent unconfined passthrough is forbidden,confined policy 下退回原 argv 属于违规,danger-full-access 要显式选择才行。
confine 的返回还带两个正交的 stderr 分类器:denialSignatures 识别「命令被沙箱正确拦下」(bwrap 的 read-only file system、Landlock 的 permission denied、Seatbelt 的 operation not permitted),runnerFailureRules 识别「沙箱 runner 自己挂了,命令没跑起来」。先查 runner failure 再查 denial,顺序反了会把沙箱故障误报成任务失败。
本地 provider:平台链 + 一次性 probe
LocalSandboxProvider 把「选哪个后端」变成一条确定性链。先看链的定义:
159const PLATFORM_CHAINS: Record<string, readonly SelectedRunner['runner'][]> = {160 linux: ['bwrap', 'landlock'],161 darwin: ['seatbelt'],162 // The Windows restricted-token runner (@deepseek-ai/dsh-sandbox-windows-acl):163 // a sole candidate, selected without a probe — its execution-time refusal164 // fails closed through its stderr signature (windows-acl-run:) and exit 127.165 win32: ['windows-acl'],166}链的语义是「平台优先、probe 其次」:Linux 先 bwrap(mount profile 最贴近模式词汇表),不可用才轮到 Landlock;macOS 与 Windows 各只有一个候选,直接选不 probe。选择逻辑在 selectRunner,结果缓存整个 provider 生命周期:
492 private selectRunner(mode: ConfinedSandboxMode): SelectedRunner {493 this.selectedRunner ??= this.chainVerdict()494 if (this.selectedRunner === 'unavailable') throw new SandboxUnavailableError(mode)495 return this.selectedRunner496 }497498 /** Walk this platform's chain: sole candidate unprobed, several probed in order, none usable → unavailable. */499 private chainVerdict(): SelectedRunner | 'unavailable' {500 const chain = this.internals.chain ?? PLATFORM_CHAINS[this.internals.platform ?? process.platform] ?? []501 const [first, ...rest] = chain502 if (first === undefined) return 'unavailable'503 // A sole candidate needs no arbitration; its execution-time refusal still fails closed.504 if (rest.length === 0) return { runner: first, enforcement: STATIC_ENFORCEMENT[first] }505 for (const runner of chain) {506 const enforcement = this.probeRunner(runner)507 if (enforcement !== 'unusable') return { runner, enforcement }508 }509 return 'unavailable'510 }probe 是功能性探测,检查执行而非版本:bwrap 的 probe 用 read-only profile 包一个 true 跑一遍,exit 0 才算可用(sandbox-local/src/index.ts:68-74);Landlock 的 probe 是 launcher 自带的 --probe,在当前进程装一个最大 ruleset,内核不执行就报 unusable。版本号判断会漏掉「有系统调用但拒绝执行」的内核,真正限制自己一次是唯一诚实的信号。
链上所有候选都不可用时抛 SandboxUnavailableError,文案写清三条退路:
131export class SandboxUnavailableError extends HarnessError {132 constructor(mode: ConfinedSandboxMode, detail?: string) {133 super(134 `sandbox mode "${mode}" is requested but no sandbox backend is usable on this host; `135 + 'refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing '136 + 'kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL '137 + 'restricted-token runner can start (Windows) — otherwise switch the consumer to '138 + 'danger-full-access.'139 + (detail === undefined ? '' : ` Runner failure: ${detail}`),140 SANDBOX_UNAVAILABLE,141 )142 this.name = 'SandboxUnavailableError'143 }144}错误消息第一句就是 refusing to run the command unconfined,这是 fail-closed 的落点。
profile:同一种 policy 翻译成三种方言
policy 要翻译成后端自己的语言,profiles.ts 里三个 builder:
16export function bwrapProfileArgs(policy: SandboxPolicy): string[] {17 const args = ['--ro-bind', '/', '/', '--dev', '/dev', '--unshare-pid', '--proc', '/proc', '--die-with-parent']18 if (policy.mode === 'workspace-write') {19 args.push('--tmpfs', '/tmp')20 args.push('--bind', policy.workspaceRoot, policy.workspaceRoot)21 }22 return args23}bwrap 的 read-only 是整棵根文件系统只读绑定加 /dev 设备节点,再加独立 PID 命名空间。workspace-write 在此基础上把 /tmp 换成 tmpfs,把 workspace root 可写绑定进去。--die-with-parent 预告本章的进程树主题:容器随父进程死掉。
Landlock 是 allow-list 语义:显式授予 --ro / 和 --rw 列表(/dev/null,workspace-write 再加 /tmp 和 workspace root),没授予的就是拒绝。Seatbelt 走 SBPL 文件:(allow default) 之后先 (deny file-write*) 再精确 (allow file-write* ...)。
landlock-run:为什么是 C
Landlock 后端为什么是一个 C11 写的、静态链接 musl 的独立 launcher?看主流程:
230static int restrict_self(const struct cli *cli, int *partial) {231 long abi = syscall(__NR_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);232 if (abi < 0) {233 /* ENOSYS: kernel built without Landlock; EOPNOTSUPP: built but disabled.234 * Either way: not enforceable — fail CLOSED, never exec unconfined. */235 return fail(NOT_ENFORCED_MESSAGE, NULL);236 }237 *partial = abi < MAX_ABI;238 uint64_t handled = fs_mask_for_abi(abi < MAX_ABI ? abi : MAX_ABI);239240 struct landlock_ruleset_attr attr = { .handled_access_fs = handled };241 int ruleset_fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof attr, 0);242 if (ruleset_fd < 0) return fail("landlock ruleset error", strerror(errno));243244 const uint64_t read_side = LL_FS_EXECUTE | LL_FS_READ_FILE | LL_FS_READ_DIR;245 for (size_t i = 0; i < cli->ro_count; i++) {246 int code = add_rule(ruleset_fd, cli->ro[i], read_side & handled);247 if (code != 0) return code;248 }249 for (size_t i = 0; i < cli->rw_count; i++) {250 int code = add_rule(ruleset_fd, cli->rw[i], handled);251 if (code != 0) return code;252 }253254 if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {255 return fail("landlock ruleset error", strerror(errno));256 }257 if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) {258 return fail("landlock ruleset error", strerror(errno));259 }260 close(ruleset_fd);261 return 0;262}核心是 self-restrict-then-exec:launcher 在自己身上装 ruleset 再 exec 被包裹的命令。Landlock 规则集跨 execve 继承,命令和它 spawn 的所有后代都受限,调用 launcher 的进程(harness 自己)不受影响。no_new_privs 是前置条件,顺带废掉沙箱内的 setuid/setgid 提权。
为什么 TS 做不了?Landlock 只有原始系统调用(landlock_create_ruleset / landlock_add_rule / landlock_restrict_self),没有 libc 包装,Node 也不暴露 raw syscall。更根本的是 self-restrict 的形态:harness 主进程不能限制自己,限制必须发生在要 exec 子命令的那个进程里,只能派一个子进程装 ruleset 再 exec,天然就是一个小 C launcher 的形状。main.c 的文件头注释说得直接:
30 * Plain C11 over the raw Landlock UAPI — no libraries beyond libc (musl,31 * linked statically), so the whole audit surface is this file plus the32 * kernel's stable syscall contract. Built natively per architecture by33 * `scripts/build.ts` into the per-platform npm packages34 * (`@deepseek-ai/node-addon-landlock-run-linux-{x64,arm64}`); the argv grammar,35 * exit codes, and report lines are pinned in `docs/cli-contract.md`.36 */fail-closed 的落点也在这里,main 的最后三行:
264int main(int argc, char **argv) {265 struct cli cli = { 0 };266 int code = parse(argc, argv, &cli);267 if (code != 0) return code;268269 if (cli.probe) {270 /* The functional probe: build and enforce a maximal ruleset in THIS271 * short-lived process (the probe run exits right after). `--version`272 * style checks would miss a kernel that has the syscalls but refuses273 * enforcement; actually restricting is the only honest signal. The one274 * report line is part of the launcher CLI contract — the executor reads275 * enforcement completeness from it. */276 static const char *probe_root = "/";277 struct cli probe = { .ro = &probe_root, .ro_count = 1 };278 int partial = 0;279 code = restrict_self(&probe, &partial);280 if (code != 0) return code;281 printf("landlock: %s\n", partial ? "partially enforced (older ABI)" : "fully enforced");282 return 0;283 }284285 int partial = 0;286 code = restrict_self(&cli, &partial);287 if (code != 0) return code;288 if (partial) {289 /* Older ABI: some handled accesses are not governed (e.g. truncate290 * before ABI 3). Still confined for everything the kernel supports —291 * report, do not refuse. */292 fprintf(stderr, "landlock-run: partial enforcement (older Landlock ABI)\n");293 }294295 execvp(cli.command[0], cli.command);296 /* exec only returns on failure. */297 return fail("exec failed", strerror(errno));298}execvp 成功后控制流不回来,只有 exec 失败才走 fail 返回 125。LAUNCHER_FAILURE_EXIT = 125(native/landlock-run/packages/entry/src/index.ts:31),用法错误、内核不执行、grant 根打不开、exec 失败都是 125。命令本身也可能恰好返回 125,判定规则是「125 状态 + landlock-run: 前缀的 stderr 行」两证据同时成立才算 launcher 失败(sandbox-local/src/index.ts:233-237)。这与第 4 章可迁移结论的「双层边界」是同一类纪律:不同性质的观测各自独立。第 4 章是 step 与 turn 两层各留日志,这里是状态码与诊断两个通道分开报,判定时才合并。
TS 侧是薄契约封装:grantArgs 把 grant 翻译成 --ro/--rw 参数,probe 同步跑 landlock-run --probe 解析 verdict:
94export function grantArgs(grants: LauncherGrants): string[] {95 return [⋯ ...(grants.readOnly ?? []).flatMap(root => ['--ro', root]),⋯ ...(grants.readWrite ?? []).flatMap(root => ['--rw', root]),98 ]99}entry 包还声明了一个安全约束:模块没有任何环境变量覆盖,「哪个二进制来限制进程」不由环境变量决定(native/landlock-run/packages/entry/src/index.ts:12-14)。环境变量是进程间最容易注入的通道,能换 launcher 路径就等于用环境换掉沙箱。这是「Never hand untrusted output the ambient environment」的具体实例。
进程树 seam:spawn 的是一棵树
沙箱包装完 argv,spawn 交给 subprocess seam。关键参数是 detached: platform !== 'win32'(packages/subprocess/subprocess-local/src/spawn.ts:358-361),POSIX 上子进程成为独立进程组的组长,teardown 有了一个树根:给整个负 PID 一个信号,免去逐个进程点名。
handle 的终止语义在 seam 类型里就写清楚了:
167export interface SubprocessHandle {168 /** Process id (tree root); -1 when the spawn itself failed. */169 readonly pid: number170 /** The child's stdin, present iff spawned with `stdin: 'pipe'`. */171 readonly stdin: Writable | undefined172 /** The child's raw stdout, present iff spawned with `stdout: 'pipe'`. */173 readonly stdout: Readable | undefined174 /** The child's raw stderr, present iff spawned with `stderr: 'pipe'`. */175 readonly stderr: Readable | undefined176 /** Offset-based readers for collect-mode streams (also readable after exit). */177 readonly collected: SubprocessCollectedOutputs178 /** Resolves at process close with exit facts; rejects only for spawn-level failures. */179 readonly done: Promise<SubprocessOutcome>180 /**181 * Begin the SIGTERM → `graceMs` → SIGKILL escalation on the process tree182 * (Windows force-terminates immediately) — the seam's only termination183 * verb. Idempotent, a no-op once the tree is gone (the pid may be reused),184 * and also triggered by the spec's abort signal.185 */186 terminate(): void187 /**188 * Wait until the process tree has exited — the tree, not just the direct189 * child, so a still-running helper is observable before teardown returns.190 * @param signal - optional bound for the wait.191 * @returns `true` when the tree exited, `false` when the signal aborted first.192 */193 waitForExit(signal?: AbortSignal): Promise<boolean>194}terminate() 是唯一的终止动词,树范围生效、幂等,也是 abort 信号的响应路径。waitForExit() 等整棵树退出,这是超时与清理衔接的关键。graceMs 由调用方配置,seam 不设默认值。
实现的升级阶梯在 terminate:
439 const terminate = (): void => {440 if (treeExitObserved || graceTimer !== undefined) return441 // Observe from the first termination tier onward, even when inherited442 // pipes delay `done` and no consumer has begun its own teardown wait.443 void observeTreeExit()444 // oxlint-disable-next-line typescript/no-unnecessary-condition -- observer can record absence before its first await.445 if (treeExitObserved) return446 kill('SIGTERM')447 // The escalation must survive direct-child settlement — the leader dying448 // does not mean the tree died — so settle does not clear this timer, and449 // kill() re-probes tree liveness before force-killing. It stays ref'd:450 // the pending SIGKILL is a commitment, and a parent exiting before it451 // fires would orphan a trapped survivor. Self-bounds at graceMs.452 graceTimer = setTimeout(() => { kill('SIGKILL') }, spec.graceMs)453 }先 SIGTERM 全组,等 graceMs,再 SIGKILL 全组。注释里有一句关键:升级必须熬过直接子进程的 settlement,leader 死了不代表树死了,所以 settle 不清 graceTimer,SIGKILL 要能到达树里的幸存者。timer 保持 ref'd,父进程不能带着未兑现的 SIGKILL 承诺先退出。
这张时序图回答:一次 terminate 如何在 TERM 与 KILL 两个时刻发信号,直到树退出。
树活着的判定:treeAlive 在 POSIX 上先 process.kill(-pid, 0),settled 之后(直接子进程已退出)才看 /proc 进程组,区分「只有僵尸还回应 kill(0)」和「真的有活成员」:
99export function linuxProcessGroupHasLiveMembers(100 processGroupId: number,101 internals: ProcessInspectorInternals = DEFAULT_INTERNALS,102): boolean | undefined {103 let entries: string[]104 try {105 entries = internals.readDir('/proc')106 } catch (_unreadableProcDirectory) {107 return undefined108 }109 let matched = false110 for (const entry of entries) {111 if (!/^\d+$/.test(entry)) continue112 const stat = readLinuxStat(internals, Number(entry))113 if (stat?.pgrp !== processGroupId) continue114 matched = true115 if (!/^[ZXx]$/.test(stat.state)) return true116 }117 return matched ? false : undefined118}只有僵尸的进程组不能被信号唤醒,teardown 等它退出是安全的;有活成员才算「还活着」。undefined 表示无法证明,调用方按存活处理。
服务 dispose:杀完还要等到 quiescence
dispose 是「杀 → 等整棵树退出」的两段式,对应 docs/defensive-patterns.md 的「Dispose must reach quiescence」:
79 private async disposeManagedProcesses(): Promise<void> {80 // Terminate (escalating), then await WHOLE-TREE exit — not just the81 // direct child's settlement — so even a TERM-trapping descendant cannot82 // outlive the fiber. Keep both sets authoritative while these waits are83 // pending so a shorter process-level exit bound can still force-kill them.84 const pending: Promise<unknown>[] = []85 for (const handle of this.live) {86 handle.terminate()87 // Spawn-failure rejections already settled and left the live set.88 pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))89 }90 for (const terminal of this.terminals) {91 pending.push(terminal.terminate())92 }93 const outcomes = await Promise.allSettled(pending)94 const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'95 ? [outcome.reason as unknown]96 : [])97 if (failures.length > 0) this.terminateForHostExit()98 this.live.clear()99 this.terminals.clear()100 if (failures.length === 1) throw failures[0]101 if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed')102 }对每个 handle:先 terminate() 启动升级,done 之后还要 waitForExit() 等整棵树。只等直接子进程会漏掉 TERM-trapping 的后代,必须等到树真正消失才能释放所有权。live 集合在整个等待期间保持权威,进程级的退出上限更短时还能再 force-kill。
还有一道兜底:Node 的同步 exit 阶段调用 terminateForHostExit(),不用 timer 不等 await,直接对每个持有的 handle 发 SIGKILL(packages/subprocess/subprocess-local/src/index.ts:62-77)。dispose 失败后也走这条路。
远程沙箱:同一套 seam,另一个世界
现在切到 packages/e2b/。E2B 是远程 Linux 沙箱服务,dsh 的 E2B 实现是整组能力 seam 的另一个版本:ctx.e2b 拥有一个沙箱,ctx.fs 和 ctx.subprocess 适配器共享同一个 SDK handle。
108 ctx.effect(() => async () => {109 this.disposed = true110 let sandbox: Sandbox111 try {112 sandbox = await this.ready113 } catch (_sandboxSetupFailure) {114 // open() either acquired no sandbox or already made the POC's one rollback attempt.115 return116 }117 try {118 await sandbox.kill()119 } catch (error: unknown) {120 if (!(error instanceof SandboxNotFoundError)) throw error121 }122 }, 'e2b sandbox teardown')123 }dispose 直接 sandbox.kill() 整个沙箱,远程进程、远程文件一次性消失。超时也是删除语义:timeoutMs 配置沙箱生命周期(e2b/src/index.ts:47-49),lifecycle: { onTimeout: 'kill' }(e2b/src/index.ts:156)。
远程进程树的终止逻辑和本地对应,但信号通过控制 shell 发:signalRemoteGroups 对一组进程组发 TERM 或 KILL:
81export async function signalRemoteGroups(82 sandbox: Sandbox,83 envs: Record<string, string>,84 groups: readonly number[],85 signal: 'TERM' | 'KILL',86): Promise<void> {87 // TODO(e2b-pgid-identity): Prefer an atomic identity-bound group signal if E2B adds one;88 // a userspace identity precheck cannot close the numeric-PGID reuse race.89 try {90 await sandbox.commands.run(91 `kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`,92 commandOpts(envs),93 )94 } catch (error: unknown) {95 if (!(error instanceof CommandExitError) && !(error instanceof SandboxNotFoundError)) throw error96 }97}远程升级阶梯 terminateGroup(packages/e2b/subprocess-e2b/src/process.ts:612-626)和本地对称:TERM → graceMs 内轮询组退出 → KILL → 组仍活着就抛「remained live after force termination」。对称来自同一个 seam 契约的两份实现。
关键认知在 packages/e2b/README.md 和共享执行世界的 Agent Note 里:
15This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers. The [portable execution-world decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns both the generic composition and this POC boundary.移动的是「可变的编码世界」(文件、命令、终端、语言服务器进程),不移动 harness 本身。bash-local、terminal-bash、lsp-stdio 没有 E2B 专用分支,只通过 ctx.fs 和 ctx.subprocess 说话,挂上两个适配器,可变工作就整体搬进了远程沙箱。
这张图回答:本地世界与远程世界共用同一组能力 seam,差异只在执行世界的位置。
设计决策分析
决策一:landlock 下沉 native
sandbox-local 对 Landlock 的依赖是一个自管生命周期、打包、探测链的 C 二进制,为什么这样做?
第一层理由:Landlock 没有 libc 包装,只有裸系统调用,Node 不给 raw syscall。更本质的第二层是 self-restrict-then-exec 的形态:限制必须发生在要 exec 子命令的那个进程里,只能是一个专用 launcher 子进程。execvp 之后命令接管进程镜像,控制流永不返回,TS 只能 spawn 不能 exec,表达不了这种形态。C 的进程模型天然匹配。
第三层是审计面。mechanism 层只有 300 行 C,静态链接 musl,无 loader 无 libc 依赖,一个文件加内核的 syscall 契约就是全部审计面。native/landlock-run 的 architecture.md 第 3 行说得更直接:这个仓库拥有机制,不拥有策略,「consumers decide which paths a run may read or write; this package family provides the launcher that enforces those grants」。策略(沙箱模式怎么映射到路径)留在 profiles.ts,机制(怎么让内核执行这些 grant)沉到 C。
不这样做会出什么事:Landlock 逻辑放 TS 里就得调 raw syscall,Node 做不到,只能依赖某个原生扩展,那个扩展会变成审计黑盒;或者用 node:child_process 调 landlock 命令行工具,但通用 Linux 发行版不保证装它,探测链退化成工具存在性的脆判断,看不出内核到底执行不执行。probe 把两种情况统一:二进制不存在和内核不执行,都返回 unusable,消费者只有一条降级路径(native/landlock-run/README.md:23)。
还有一层安全收益:entry 包不读任何环境变量。环境变量是进程间最容易被注入的通道,launcher 路径或 grant 列表若能由环境变量改写,一个环境变量就能换掉沙箱。移出环境变量的决策域,攻击路径就没了。
决策二:fs 与 subprocess 共享一个执行世界
沙箱和进程树是一个问题的两面。harness 要防的是一段不受信任的代码,它可以写文件、spawn 进程、开终端、起语言服务器。文件沙箱和进程沙箱各自独立配置,会出现「文件被限制但命令能绕过」或相反的空洞。共享执行世界的决策把 ctx.fs 与 ctx.subprocess 绑成一个世界:同一套路径命名空间、可执行文件、进程、终端会话(Agent Note 2026-07-28-portable-execution-world-consumers.md:17)。
验证是 E2B POC:三个 provider 包(一个 owner + 两个适配器)把整个可变编码世界搬进远程沙箱,bash/PTY/LSP 消费者零改动(packages/e2b/README.md:13)。不共享世界的话,远程方案要每个消费者写 E2B 专用分支,Agent Note 的「Alternatives considered」第一条拒绝的正是它。
不这样做会出什么事:本地,fs 沙箱和进程沙箱策略分叉,命令可以借一个能力 seam 绕过另一边的限制;远程,每次能力调用各建各的沙箱,文件操作和命令操作不在同一个沙箱里,编码用例直接失效。Agent Note 拒绝时写了:file and process operations would not share identity or state, defeating the coding use case。
决策三:超时与进程树清理是一件事
工具层的超时和进程树清理衔接:超时触发 abort,abort 触发 terminate(),升级杀树,dispose 等到 quiescence。每个环节只做自己那一步,漏一环就出现「超时了但进程还在跑」。
杀树比杀直接子进程复杂,因为后台任务可能捕获 TERM、重新挂到别的父进程下、变成僵尸。第 4 章可迁移结论的「双层边界」延伸为「树是真实的边界」:waitForExit 等树、observeTreeExit 只记录一次树的消失(防 pid 复用后误杀他人)、treeAlive 区分活成员和僵尸。指向同一个判断:子进程的 settlement 与树的 quiescence 是两个事实,必须分开观测。
graceMs 还有 seam 层面的把关:必须能表示为 Node timer(spawn.ts:327-329 校验正有限且不超过 MAX_TIMER_DELAY_MS),e2b 适配器重复了校验(subprocess-e2b/src/index.ts:45-49)。远程实现里它同时是轮询退出的 deadline(process.ts:644),两处语义相同:grace 是「从 TERM 到 KILL 的耐心上限」。
不这样做会出什么事:进程漏杀。只杀直接子进程,sh -c 'sleep 1000 &' 里的 sleep 就漏了;杀了不等树,TERM-trapping 的后代在 dispose 返回后继续跑,变成孤儿。这是 defensive-patterns.md 点名的事故:「A teardown that issues kills/aborts but returns before the work stops leaves orphans」。
这张状态图回答:一次 confined 执行从解析 policy 到整棵树 quiescent 的治理状态机,danger-full-access 从 confining 分叉出去。
决策四:fail-closed 与第二道防线
整章贯穿一个模式:每道防线都写明「挡不住什么」,fail-closed 是「做不到就拒绝运行」的承诺。沙箱 unavailable 拒绝运行(SandboxUnavailableError),Landlock 内核不执行就不 exec(main.c:233-235),runner 失败与任务失败区分开(RunnerFailureRule)。
「第二道防线」要说清楚:防线之间是分层的职责边界,各管一类攻击,下一层接住上一层的残余。docs/subsystems/sandbox.md:5 定位准确:Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of ctx.sandbox。远程沙箱是「换一个执行世界」的整组方案。这是安全性的质变:本地沙箱共享宿主内核,逃逸(Landlock 只拦文件效果,拦不住网络、进程可见性、内核漏洞利用)的后果由宿主承担;远程沙箱里逃逸的后果由一次性沙箱承担,sandbox.kill() 抹掉所有痕迹。挡不住的部分由部署模型负责。
边界条件剖析
边界一:宿主机一个沙箱后端都没有
selectRunner 抛 SandboxUnavailableError(sandbox-local/src/index.ts:494),错误穿过 tool/result 结构化错误通道,消费者能看到 SANDBOX_UNAVAILABLE 代码,把它和命令失败区分开(sandbox/src/index.ts:120-123)。错误在 wrap 时抛出,命令根本没 spawn。
边界二:Landlock ABI 太旧
ABI 协商在 fs_mask_for_abi(main.c:185-191):ABI 1 管 13 个访问位,ABI 2 加 REFER,ABI 3 加 TRUNCATE,ABI 5 加 IOCTL_DEV。内核只支持旧 ABI 时,规则集缩小到内核能管的子集,partial 置位(main.c:237),launcher 仍然 exec 命令,stderr 报一行 partial enforcement,probe 把 verdict 报成 partial(main.c:281),provider 如实带出(sandbox-local/src/index.ts:527)。分支是「报告,不拒绝」,和「无法执行就拒绝」的 fail-closed 是两回事:partial 意味着被管的部分仍然被管,unusable 什么都没管。
边界三:pid 复用
waitForExit 等树时,树退出后 pid 可能被系统立刻分配给别的进程,再发信号会打到无辜者身上。防线在 observeTreeExit:第一次确认树消失后置 treeExitObserved,之后的信号都被短路(spawn.ts:384),graceTimer 也被清掉。Linux 上还有一道:treeAlive 在直接子进程 settled 之后查 /proc 确认组里没有活成员(process-inspector.ts:99-118),只有僵尸的组回答 kill(0) 但不发信号。两道合起来保证:信号只发给「被证明还活着」的树。
边界四:远程 pgid 复用
e2b 侧没有 identity 防线。signalRemoteGroups 的注释自己坦承:a userspace identity precheck cannot close the numeric-PGID reuse race(remote.ts:87-88),上游提供原子 identity 绑定信号之前,这个竞态是开放边界。代码做了能做的部分:发布文件里的 pgid 必须大于 1,拒绝 kill -- -1(process.ts:501-503,负 PID 会发给所有进程);发布格式严格校验,非法值直接抛错(process.ts:496-498)。修复依赖上游。
横向对比
对比对象是 claude-code 的沙箱方案。检索关键词:sandbox、landlock、seccomp、seatbelt、bwrap、deny、permission、jail、restricted,在公开仓库(commit 45bdfa96)里未找到主产品沙箱实现源码,只有配置面与行为记录。
行为记录在 CHANGELOG.md,Linux 沙箱有文件 deny 规则、网络域名单、凭据掩码,修复记录密集:
48- Fixed the Linux sandbox making a nonexistent `.git/config.worktree` unreadable, which broke every sandboxed git command in repos with `extensions.worktreeConfig` set124- Sandbox: on macOS, wildcard read-deny rules (e.g. `**/.env`) now take precedence inside allowed read regions, cover matched directories' contents, and can't be bypassed by renaming the denied file配置面在 examples/settings/ 里。公开的 sandbox 配置结构长这样:
1{2 "allowManagedPermissionRulesOnly": true,3 "sandbox": {4 "enabled": true,5 "autoAllowBashIfSandboxed": false,6 "allowUnsandboxedCommands": false,7 "excludedCommands": [],8 "network": {9 "allowUnixSockets": [],10 "allowAllUnixSockets": false,11 "allowLocalBinding": false,12 "allowedDomains": [],13 "httpProxyPort": null,14 "socksProxyPort": null15 },16 "enableWeakerNestedSandbox": false17 }18}最说明问题的一句在 README 里:
27- The `sandbox` property only applies to the `Bash` tool; it does not apply to other tools (like Read, Write, WebSearch, WebFetch, MCPs), hooks, or internal commands这就是同一问题的两种答案。dsh:隔离是 capability seam,每次能力调用带 policy,所有执行型能力共享同一套词汇表(文件效果)和同一套执行世界(fs + subprocess),远程整体替换。claude-code:隔离是 Bash 工具的附属配置,只作用于 Bash 工具,其他工具(Read、Write、WebSearch、MCP、hooks)由 permission 系统单独覆盖。代价差异是结构性的:dsh 里任何新执行型能力自动继承沙箱词汇表,但 seam 抽象本身要维护(provider 链、probe、dialect 分类);claude-code 配置面极窄,新工具靠 permission 系统补位,README 声明了边界。
两个方案有一个共同点:都承认隔离是工具执行策略,不是 harness 全局属性,都留了显式逃生通道。dsh 是 danger-full-access(消费者显式选择不调用 ctx.sandbox),claude-code 是 allowUnsandboxedCommands 和 excludedCommands。区别在默认方向:dsh 默认 confined、逃生要显式;claude-code 的 sandbox 默认不启用(enabled: true 要显式配置)。dsh 把「默认只读」当作 fail-safe 缺省,两边威胁模型假设不同。
互动演示设计
演示形态是决策沙盘,主题是威胁模型分层。结论:harness 对不受信任代码的执行设了四道防线,每道挡一类攻击,挡不住的由下一层接手,沙箱出问题时的默认动作是拒绝运行。
舞台是一栋四层楼的剖面图,每层一个房间,房间里有守卫。楼名「信任边界公寓」。
- 第一层,工具门卫:工具 schema 和审批流程。挡「直接请求执行某命令」,挡不住「命令内容里藏的动作」。道具:门禁卡。
- 第二层,文件效果守卫:bwrap / Landlock / Seatbelt / ACL 的沙箱。挡「越界文件读写」,挡不住网络和进程可见性(词汇表外)。道具:权限清单(allow-list)。
- 第三层,进程树管理员:detached 进程组、SIGTERM 到 SIGKILL 的升级、等整棵树退出。挡「漏杀的后台进程」,挡不住 pid 复用竞态。道具:组信号喇叭。
- 第四层,世界管理员(远程沙箱):整体换执行世界,
sandbox.kill()抹掉一切。挡「内核漏洞利用的后果留在宿主机」,代价是一次性世界的开销。道具:搬家车。
这张图回答:四道防线各挡什么,挡不住的由下一层接手。
左右两侧各放一条执行链:左链「本地世界」(bwrap → spawnSubprocess → 进程树),右链「远程世界」(E2B owner → fs-e2b + subprocess-e2b → 远程进程组)。切换开关把同一个 Bash 请求发到任一条链,两侧跑同一个脚本,观察包装 argv 与清理轨迹。
分步字幕:
- 「模型说:跑一下这个脚本。第一道防线要审的东西。」
- 「工具层批准,把请求变成 argv,向沙箱要一个 confined 包装。」
- 「沙箱查平台链:Linux 先试 bwrap,probe 失败就试 landlock,全失败就拒绝运行。」
- 「包装后的 argv 交给进程树 seam:detached 进程组,超时杀整组,先 TERM 后 KILL。」
- 「切换到右链:同一个请求,三个 E2B 包把它搬进远程世界,文件、命令、终端一起走。」
- 「沙箱到期或销毁:远程世界整体删除;本地世界把每一棵树等到 quiescence。」
读者操作:拖动「攻击样例」卡片到某一层。卡片是无害样例:「脚本里写了一个后台 sleep」拖到第三层,亮起「杀整棵树」,字幕:这一层挡漏杀,挡不住它连外网,外网由网络策略接;「脚本想写 /etc 下的文件」拖到第二层,亮起 deny 签名匹配,字幕:这一层挡越界文件效果;「宿主内核本身有洞」拖到第二层,守卫摊手:这一层拦不住内核漏洞,把执行世界搬走是另一个答案。
逻辑轨迹面板的伪代码,右侧标真实行号:
policy = resolve(session) # sandbox-policy/src/index.ts:135
argv2 = sandbox.confine(argv, policy) # sandbox/src/index.ts:175
runner = platformChain[process.platform][0] # sandbox-local/src/index.ts:159
if probe(runner) unusable: try next # sandbox-local/src/index.ts:505
throw SANDBOX_UNAVAILABLE # sandbox/src/index.ts:131
child = spawn(argv2, detached) # subprocess-local/src/spawn.ts:350
terminate: SIGTERM then SIGKILL after grace # subprocess-local/src/spawn.ts:439
waitForExit: observe whole tree # subprocess/src/types.ts:193
remote: kill -TERM -- -pgid # process.ts:615随动画步进高亮对应行,每行一句白话,不解释语法。
可迁移结论
-
机制与策略分层,值得抄。C launcher 只做「装 ruleset、exec」,TS 层只做「policy 到参数的翻译」。抄的最小形态:先抄分层本身,把「允许什么」和「怎么执行允许」分成两个模块,策略层可以热改,机制层锁契约。
-
fail-closed 的默认方向,值得抄。沙箱不可用就拒绝运行,不提供带着警告继续的中间档。抄的最小形态:任何安全相关依赖探测失败时,默认拒绝执行,给出可操作的错误消息(缺什么、装什么、或显式降级)。
SandboxUnavailableError的文案是现成模板:告诉用户三条退路。 -
进程树是一个真实边界,值得抄。detached 进程组 + TERM 到 KILL 的升级 + 等整棵树退出,这条不依赖 TypeScript,任何语言的进程管理都能落地。最小形态:POSIX 上 spawn 时加
setsid,终止时先kill(-pgid, SIGTERM),等graceMs再kill(-pgid, SIGKILL),退出前确认组里没有活成员。Python 的subprocess.Popen(start_new_session=True)加os.killpg就是完整实现,大概 20 行。它解决的是「超时杀不死后台进程」这个通用问题。 -
按威胁模型分层,不堆纵深。每道防线写清楚「挡什么、挡不住什么、下一层是谁」,比无限加层有效。过度设计也有:本地 + 远程双世界的完整 seam(provider 链、probe、dialect 分类、runner failure 规则)是 harness 这个量级才需要的;个人工具或小服务,一个 bwrap 包装函数加一个 SIGKILL 兜底就够。E2B 的「发布 pgid 再轮询」控制平面是远程无原子信号 API 下的妥协产物,无远程场景就不需要。
思考题
-
动手题:在
packages/subprocess/subprocess-local/tests/写一个小实验,验证「杀树不等树」的后果。跑sh -c 'sleep 30 & sleep 1'这类无害命令,只对直接子进程发 SIGTERM,观察后台 sleep 是否存活;再用handle.terminate()加waitForExit(),观察树是否完整退出。跑pnpm --filter @deepseek-ai/dsh-subprocess-local test -- spawn看现有关卡怎么测。 -
读
profiles.ts的三个 profile builder,回答:同一个workspace-writepolicy,为什么 bwrap 用 mount 表达、Landlock 用 allow-list 表达、Seatbelt 用 SBPL deny 加 allow 表达?三种表达各自的失败模式是什么?(提示:从默认方向想,bwrap 默认全只读,Landlock 默认全拒绝,SBPL 默认全允许。) -
假设把
danger-full-access也允许传给 provider,ConfinedSandboxMode约束就会放宽。想一想confine()的契约会怎么变,silent unconfined passthrough is never legal还成立吗?改packages/sandbox/sandbox/src/index.ts的类型定义,跑 typecheck 观察哪里开始报错。 -
读
packages/e2b/subprocess-e2b/src/process.ts的waitForProcessGroupId(第 484-509 行),它拒绝pid <= 1的发布值。为什么 1 危险?如果发布文件被篡改成 2(init 的组),会发生什么,哪一层接住它?