一切皆插件:DeepSeek Harness 源码精读 8 / 14

第 8 章

沙箱与进程树

源码核对基于 deepseek-ai/deepseek-harness commit `b150a551b8`(2026-08-21),tag `course-anchor-20260823`对比语料 anthropics/claude-code commit `45bdfa96`(2026-08-23),tag `v2.1.241`本章字数:约 9920 字(不含代码与图)

场景还原

你在终端里跑一个本地 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、终端都读它,沙箱模式每次能力调用时重新解析。

packages/sandbox/sandbox-policy/src/index.ts135:142
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-onlysandbox-policy/src/index.ts:94),fail-safe 方向:没配置就只读,可写要显式配置。

模式词汇表与 enforcement 事实

解析出的 policy 落到三类模式。SandboxMode 只管文件效果,网络和进程可见性不在词汇表里。

packages/sandbox/sandbox/src/index.ts29:29
29export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'
docs/subsystems/sandbox.md23:23
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 的诚实声明,后端能执行到什么程度是报告出来的事实:

docs/subsystems/sandbox.md30:30
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。它的契约是这一章的核心:

packages/sandbox/sandbox/src/index.ts152:176
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 把「选哪个后端」变成一条确定性链。先看链的定义:

packages/sandbox/sandbox-local/src/index.ts159:166
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 生命周期:

packages/sandbox/sandbox-local/src/index.ts492:510
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,文案写清三条退路:

packages/sandbox/sandbox/src/index.ts131:144
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:

packages/sandbox/sandbox-local/src/profiles.ts16:23
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?看主流程:

native/landlock-run/packages/entry/src/main.c230:262
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 的文件头注释说得直接:

native/landlock-run/packages/entry/src/main.c30:36
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 的最后三行:

native/landlock-run/packages/entry/src/main.c264:298
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 = 125native/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:

native/landlock-run/packages/entry/src/index.ts94:99
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 类型里就写清楚了:

packages/subprocess/subprocess/src/types.ts167:194
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

packages/subprocess/subprocess-local/src/spawn.ts439:453
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 两个时刻发信号,直到树退出。

sequenceDiagram participant C as Consumer participant H as SubprocessHandle participant T as ProcessTree C->>H: terminate H->>T: SIGTERM group T-->>H: treeAlive probe alt tree still alive H->>H: graceMs timer stays refd H->>T: SIGKILL group end T-->>H: tree exited observed H->>C: waitForExit resolves true

树活着的判定:treeAlive 在 POSIX 上先 process.kill(-pid, 0),settled 之后(直接子进程已退出)才看 /proc 进程组,区分「只有僵尸还回应 kill(0)」和「真的有活成员」:

packages/subprocess/subprocess-local/src/process-inspector.ts99:118
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」:

packages/subprocess/subprocess-local/src/index.ts79:102
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.fsctx.subprocess 适配器共享同一个 SDK handle。

packages/e2b/e2b/src/index.ts108:123
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:

packages/e2b/subprocess-e2b/src/remote.ts81:97
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}

远程升级阶梯 terminateGrouppackages/e2b/subprocess-e2b/src/process.ts:612-626)和本地对称:TERM → graceMs 内轮询组退出 → KILL → 组仍活着就抛「remained live after force termination」。对称来自同一个 seam 契约的两份实现。

关键认知在 packages/e2b/README.md 和共享执行世界的 Agent Note 里:

packages/e2b/README.md15:15
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.fsctx.subprocess 说话,挂上两个适配器,可变工作就整体搬进了远程沙箱。

这张图回答:本地世界与远程世界共用同一组能力 seam,差异只在执行世界的位置。

flowchart TB subgraph Host LocalFS[fs-local] --> E2BFS[fs-e2b adapter] LocalSub[subprocess-local] --> E2BSub[subprocess-e2b adapter] E2BOwner[e2b owner Sandbox.create] end E2BFS --> SB[remote sandbox] E2BSub --> SB E2BOwner --> SB SB --> Tree[remote process tree] Tree --> Kill[sandbox.kill on teardown]

设计决策分析

决策一: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_processlandlock 命令行工具,但通用 Linux 发行版不保证装它,探测链退化成工具存在性的脆判断,看不出内核到底执行不执行。probe 把两种情况统一:二进制不存在和内核不执行,都返回 unusable,消费者只有一条降级路径(native/landlock-run/README.md:23)。

还有一层安全收益:entry 包不读任何环境变量。环境变量是进程间最容易被注入的通道,launcher 路径或 grant 列表若能由环境变量改写,一个环境变量就能换掉沙箱。移出环境变量的决策域,攻击路径就没了。

决策二:fs 与 subprocess 共享一个执行世界

沙箱和进程树是一个问题的两面。harness 要防的是一段不受信任的代码,它可以写文件、spawn 进程、开终端、起语言服务器。文件沙箱和进程沙箱各自独立配置,会出现「文件被限制但命令能绕过」或相反的空洞。共享执行世界的决策把 ctx.fsctx.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 分叉出去。

stateDiagram-v2 [*] --> Resolved: policy resolve Resolved --> Confined: confine wraps argv Resolved --> DirectSpawn: danger-full-access Confined --> Running: spawn detached tree Running --> Terminating: abort or terminate Terminating --> Running: tree still alive Terminating --> Quiescent: tree exited Running --> Quiescent: normal exit Quiescent --> [*] DirectSpawn --> Running

决策四: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() 抹掉所有痕迹。挡不住的部分由部署模型负责。

边界条件剖析

边界一:宿主机一个沙箱后端都没有

selectRunnerSandboxUnavailableErrorsandbox-local/src/index.ts:494),错误穿过 tool/result 结构化错误通道,消费者能看到 SANDBOX_UNAVAILABLE 代码,把它和命令失败区分开(sandbox/src/index.ts:120-123)。错误在 wrap 时抛出,命令根本没 spawn。

边界二:Landlock ABI 太旧

ABI 协商在 fs_mask_for_abimain.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 报成 partialmain.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 -- -1process.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 规则、网络域名单、凭据掩码,修复记录密集:

claude-code/CHANGELOG.md48:48
48- Fixed the Linux sandbox making a nonexistent `.git/config.worktree` unreadable, which broke every sandboxed git command in repos with `extensions.worktreeConfig` set
claude-code/CHANGELOG.md124:124
124- 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 配置结构长这样:

claude-code/examples/settings/settings-bash-sandbox.json1:18
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 里:

claude-code/examples/settings/README.md27:27
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 是 allowUnsandboxedCommandsexcludedCommands。区别在默认方向:dsh 默认 confined、逃生要显式;claude-code 的 sandbox 默认不启用(enabled: true 要显式配置)。dsh 把「默认只读」当作 fail-safe 缺省,两边威胁模型假设不同。

互动演示设计

演示形态是决策沙盘,主题是威胁模型分层。结论:harness 对不受信任代码的执行设了四道防线,每道挡一类攻击,挡不住的由下一层接手,沙箱出问题时的默认动作是拒绝运行。

舞台是一栋四层楼的剖面图,每层一个房间,房间里有守卫。楼名「信任边界公寓」。

这张图回答:四道防线各挡什么,挡不住的由下一层接手。

flowchart TB L1[工具层\n挡主动指令请求] --> L2[沙箱文件效果层\n挡越界文件读写] L2 --> L3[进程树层\n挡漏杀与孤儿] L3 --> L4[远程世界层\n挡宿主内核后果] L2 -.->|挡不住网络与进程可见性| L3 L3 -.->|pid 复用竞态| L4

左右两侧各放一条执行链:左链「本地世界」(bwrap → spawnSubprocess → 进程树),右链「远程世界」(E2B owner → fs-e2b + subprocess-e2b → 远程进程组)。切换开关把同一个 Bash 请求发到任一条链,两侧跑同一个脚本,观察包装 argv 与清理轨迹。

分步字幕:

  1. 「模型说:跑一下这个脚本。第一道防线要审的东西。」
  2. 「工具层批准,把请求变成 argv,向沙箱要一个 confined 包装。」
  3. 「沙箱查平台链:Linux 先试 bwrap,probe 失败就试 landlock,全失败就拒绝运行。」
  4. 「包装后的 argv 交给进程树 seam:detached 进程组,超时杀整组,先 TERM 后 KILL。」
  5. 「切换到右链:同一个请求,三个 E2B 包把它搬进远程世界,文件、命令、终端一起走。」
  6. 「沙箱到期或销毁:远程世界整体删除;本地世界把每一棵树等到 quiescence。」

读者操作:拖动「攻击样例」卡片到某一层。卡片是无害样例:「脚本里写了一个后台 sleep」拖到第三层,亮起「杀整棵树」,字幕:这一层挡漏杀,挡不住它连外网,外网由网络策略接;「脚本想写 /etc 下的文件」拖到第二层,亮起 deny 签名匹配,字幕:这一层挡越界文件效果;「宿主内核本身有洞」拖到第二层,守卫摊手:这一层拦不住内核漏洞,把执行世界搬走是另一个答案。

逻辑轨迹面板的伪代码,右侧标真实行号:

text
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

随动画步进高亮对应行,每行一句白话,不解释语法。

可迁移结论

  1. 机制与策略分层,值得抄。C launcher 只做「装 ruleset、exec」,TS 层只做「policy 到参数的翻译」。抄的最小形态:先抄分层本身,把「允许什么」和「怎么执行允许」分成两个模块,策略层可以热改,机制层锁契约。

  2. fail-closed 的默认方向,值得抄。沙箱不可用就拒绝运行,不提供带着警告继续的中间档。抄的最小形态:任何安全相关依赖探测失败时,默认拒绝执行,给出可操作的错误消息(缺什么、装什么、或显式降级)。SandboxUnavailableError 的文案是现成模板:告诉用户三条退路。

  3. 进程树是一个真实边界,值得抄。detached 进程组 + TERM 到 KILL 的升级 + 等整棵树退出,这条不依赖 TypeScript,任何语言的进程管理都能落地。最小形态:POSIX 上 spawn 时加 setsid,终止时先 kill(-pgid, SIGTERM),等 graceMskill(-pgid, SIGKILL),退出前确认组里没有活成员。Python 的 subprocess.Popen(start_new_session=True)os.killpg 就是完整实现,大概 20 行。它解决的是「超时杀不死后台进程」这个通用问题。

  4. 按威胁模型分层,不堆纵深。每道防线写清楚「挡什么、挡不住什么、下一层是谁」,比无限加层有效。过度设计也有:本地 + 远程双世界的完整 seam(provider 链、probe、dialect 分类、runner failure 规则)是 harness 这个量级才需要的;个人工具或小服务,一个 bwrap 包装函数加一个 SIGKILL 兜底就够。E2B 的「发布 pgid 再轮询」控制平面是远程无原子信号 API 下的妥协产物,无远程场景就不需要。

思考题

  1. 动手题:在 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 看现有关卡怎么测。

  2. profiles.ts 的三个 profile builder,回答:同一个 workspace-write policy,为什么 bwrap 用 mount 表达、Landlock 用 allow-list 表达、Seatbelt 用 SBPL deny 加 allow 表达?三种表达各自的失败模式是什么?(提示:从默认方向想,bwrap 默认全只读,Landlock 默认全拒绝,SBPL 默认全允许。)

  3. 假设把 danger-full-access 也允许传给 provider,ConfinedSandboxMode 约束就会放宽。想一想 confine() 的契约会怎么变,silent unconfined passthrough is never legal 还成立吗?改 packages/sandbox/sandbox/src/index.ts 的类型定义,跑 typecheck 观察哪里开始报错。

  4. packages/e2b/subprocess-e2b/src/process.tswaitForProcessGroupId(第 484-509 行),它拒绝 pid <= 1 的发布值。为什么 1 危险?如果发布文件被篡改成 2(init 的组),会发生什么,哪一层接住它?