第三部分 · 能力接缝

第 8 章:沙箱与代码执行

wrap-argv 接缝、无 root 的 Landlock addon、平台探测链,以及把模型当敌意对端的代码运行时

一个只做一件事的接缝:wrap argv

沙箱接缝(packages/sandbox/sandbox)的 Service Definition 只有一个方法。它不管进程、不管执行、不管 kill——它只做一件事:把要 spawn 的 argv 包装成受限的 argv

/**
 * Abstract process-sandbox service. {@link confine} must return enforcing argv
 * or fail closed at wrap or runner-execution time; silent unconfined passthrough
 * is forbidden. ...
 */
export abstract class SandboxProvider extends Service {
  constructor(ctx: Context) {
    super(ctx, 'sandbox')
  }

  /**
   * Wrap `argv` so it executes confined under `policy` on this host; the
   * caller spawns the returned argv in place of its own.
   * @param argv - the exact argv the caller is about to spawn (program plus
   *   arguments), NOT a shell string — a shell-shaped consumer passes
   *   `['bash', '-c', command]`.
   */
  abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
}

三个正交事实被塞进这一个返回类型:

  • argv:runner + profile + -- + 原 argv;
  • enforcement: 'full' | 'partial':受限程度(不是「有没有沙箱」);
  • denialSignatures + runnerFailureRules:本后端的「拒绝方言」(bwrap 报 EROFS、Landlock 报 EACCES、Seatbelt 报 EPERM)与「runner 没跑起来」的判别规则——消费者拿它们把失败归因成「被沙箱拦住」还是「沙箱本身坏了」。

policy 是每调用携带的,不是 provider 上的固定值:两个消费者可以同时用不同 mode(bash 用 read-only 而受限子代理要写状态目录);一次获批的升级重试就是一次更宽 policy 的新调用。mode 只有三档(read-only / workspace-write / danger-full-access),只描述文件效果——网络与进程可见性不在词汇内,那是别的接缝的事。

无可用后端时抛 SandboxUnavailableErrorSANDBOX_UNAVAILABLE)fail-closed——禁止静默无沙箱直通

Landlock:300 行 C 的无 root 沙箱

Linux 上的候选后端之一是自研的 native addon:native/landlock-run/@deepseek-ai/node-addon-landlock-run)。它是一个 self-restrict-then-exec 的 Landlock 启动器:约 300 行 C11、静态链接 musl、直接调 raw kernel UAPI。它在自己身上装好 ruleset 再 exec 包装的命令——ruleset 随 execve 继承,命令及它 spawn 的一切进程都被限制,而调用进程本身不受限。Fail-closed:内核执行不了就退出而不运行命令。

static int restrict_self(const struct cli *cli, int *partial) {
  long abi = syscall(__NR_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION);
  if (abi < 0) {
    /* ENOSYS: kernel built without Landlock; EOPNOTSUPP: built but disabled.
     * Either way: not enforceable — fail CLOSED, never exec unconfined. */
    return fail(NOT_ENFORCED_MESSAGE, NULL);
  }
  *partial = abi < MAX_ABI;
  uint64_t handled = fs_mask_for_abi(abi < MAX_ABI ? abi : MAX_ABI);
  // ... create ruleset, add --ro/--rw path-beneath rules ...
  if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
    return fail("landlock ruleset error", strerror(errno));
  }
  if (syscall(__NR_landlock_restrict_self, ruleset_fd, 0) != 0) {
    return fail("landlock ruleset error", strerror(errno));
  }
  close(ruleset_fd);
  return 0;
}

为什么选 Landlock(main.c 头部注释交代):在 bwrap 不可用的 Linux 主机上(未安装、非特权 user namespace 被禁用、LSM profile 拒绝 mount)作为后备——Landlock 是独立 syscall 家族,不需要这些前提;且无 root(unprivileged restrict 强制先 PR_SET_NO_NEW_PRIVS,顺带中和 setuid/setgid 提权)。ABI 协商从 MAX_ABI 5 向下缩放,旧 ABI 只覆盖部分访问位就报 partial enforcement——照常受限但不假装全量。Node 侧 API(packages/entry/src/index.ts)只有三个函数:launcherPath()(解析 per-platform 包路径)、probe()功能探测而非版本检查:有 syscall 但拒绝执行的核也报 unusable)、grantArgs({readOnly, readWrite})。全程不读环境变量——「哪个二进制限制进程,绝不由环境决定」。

平台链:没有万能钥匙,就做一把能探测的钥匙链

本地 provider(sandbox-local)按平台选 runner 链:

linux:  ['bwrap', 'landlock']        // 双 rung,互为后备
darwin: ['seatbelt']                 // sandbox-exec -p
win32:  ['windows-acl']              // 受限令牌 runner

链上唯一候选不探测直接选;多个候选按序做功能探测(真跑一个受限进程,bwrap --ro-bind / / ... -- true;landlock 探测由 addon 提供),谁通选谁。静态裁定 STATIC_ENFORCEMENT:bwrap/seatbelt/landlock 为 full,windows-acl 为 partial(NTFS 硬链接可别名等限制)。Windows 的 ACL 方案值得一提:workspace 级常驻 ACE(跨会话复用缓存)+ 每个会话对随机私有临时目录的写授权(dispose 时撤销)——用 Windows 自己的权限模型实现「工作区可写、其余只读」。

消费者怎么用(bash-sandbox):

const confined = this.confine(spec.command, { ...policy, mode })
let result: ShellRunResult
try {
  result = await this.runArgv(spec, confined.argv)
} catch (error) {
  // An upstream abort remains cancellation even when it prevents spawn.
  if (spec.signal?.aborted === true) spec.signal.throwIfAborted()
  if (isRunnerSpawnFailure(error, confined.argv[0], spec.workdir)) {
    throw new SandboxUnavailableError(mode, String(error))
  }
  throw error
}
// Runner failure outranks denial because the command did not run. ...
const runnerFailure = classifyRunnerFailure(result.exitCode, result.stderr.text, confined.runnerFailureRules)
if (runnerFailure !== undefined) {
  throw new SandboxUnavailableError(mode, runnerFailure.detail)
}
return { ...result, sandbox: { mode, denied: classifyDenial(result, confined.denialSignatures), enforcement: confined.enforcement } }

SandboxBashExecutor extends LocalBashExecutor,注册为 ctx.shell——工具层零改动(第 7 章的换插头)。

升级阶梯与审批

工具 schema 里的 sandbox_permissions + justification 成对出现。执行前走 approveEscalationsandbox/src/escalation.ts):先查 WIDER_MODES 严格加宽阶梯(read-only → [workspace-write, danger-full-access]),再解析审批通道(EscalationApprover 是结构化函数形状,由工具层闭包 ctx.approval.request(...),本包不依赖 approval 包);无 agent / 无审批通道均 fail-closed;allowed-once 只把 mode 盖到这一次调用。模型侧收到机器可读的拒绝标记([sandbox: file access denied under <mode> mode])与升级提示([sandbox: escalation available — ...])——tool-bash 与 tool-fs 共用这一套,文本不漂移。

e2b:把整个执行世界搬到远端

第 7 章预告的 e2b 在这里兑现。ctx.e2bpackages/e2b/e2b)是一个远程沙箱的生命周期所有者:构造即创建、准备 cwd 与私有 cwd/.dsh-e2b(校验非符号链接、chmod 700)、timeout/dispose 时 kill()。两个 adapter 共享同一个 SDK handle:

  • E2BFileSystem extends FileSystem:所有操作翻译成 sandbox.files.* + sandbox.commands.runcanonicalPathrealpath -mz | base64 -w0 把远程路径以 NUL 帧 base64 传回;写用「随机 staging 目录 + chmod 700 + rename/守卫式 ln -T」原子提交;每 targetKey 一个 withLock 串行化。
  • E2BSubprocessRuntime extends SubprocessRuntimeresolveExecutablecommand -v、spawn 到远程状态目录(pid/exit-code/environment/stdout.log/stderr.log)、输出经 base64 帧流式回传、signalRemoteGroupskill -TERM/-KILL 杀远程进程组。

验证点:dsh-bash-localdsh-terminal-bashdsh-lsp-stdio 没有任何 E2B 专属 fork——它们把执行世界操作全部委托给 ctx.fs/ctx.subprocess,挂上两个 E2B adapter 就把它们的可变工作整体搬进同一沙箱。README 坦白这是「experimental provider-composition POC」(无模板/卷/快照,不是 whole-harness runtime),但已有真 e2e 测试。容器/microVM/远端执行ctx.sandbox 的后端——它们整体替换整条 capability seam,这是刻意为之的边界。

code-runtime:把模型当敌意对端

代码执行接缝(packages/code-runtime/code-runtime)是 Code Mode(第 5 章)的执行底座。Service Definition 极简:run(request: CodeRunRequest): Promise<CodeRunResult>language/isolation信息性字段(非安全声明),错误是结果里的字段CodeRunFailure.kind:exception/timeout/abort/worker-exit/invalid-output/output-limit)——run() 只对契约误用 reject。

worker-thread provider(code-runtime-worker-thread)的实现是一堂「不信任输入」课:

const worker = new Worker(WORKER_PATH, {
  workerData: bootData,
  // Model code gets NO ambient environment — stronger than the scrubbed
  // env the defensive-patterns rule requires for spawned commands.
  env: {},
  execArgv: [],
  resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
  stdout: true,
  stderr: true,
})
  • 每个程序一个全新 worker,无池化;host 侧 stripTypeScriptTypes 在 async 函数壳里剥类型(位置保持),剥完按字节切回原程序。
  • 端口按敌意对端处理parseWorkerMessage 逐字段重建,伪造字段不落地;binding 只查 own property。
  • 忙时计量computeMs 计 worker 实测事件循环忙时(eventLoopUtilization() 25ms 轮询)——热循环无法靠挂起的调度掩盖、等慢工具不累计;maxWallMs 兜底。
  • 无损 JSON 边界:binding 函数跨序列化边界必须 CodeJsonValuePORTABLE_RESERVED_WORDS = ECMAScript ∪ Python 保留字并集——「一个后端合法的命名空间清单在一切后端合法」。
沙箱与代码运行时是两回事

worker 线程是 containment 而非安全边界(信任姿态与 bash 等价);真正的系统边界是 ctx.sandbox 的 wrap-argv 接缝。dsh 没有把「代码执行」塞进沙箱接缝,也没有把沙箱塞进代码运行时——两个接缝各管各的,组合由消费者决定(bash-sandbox 可以把 bash 送进 Landlock,run_code 的 worker 则默认裸跑)。

实践应用

  1. 沙箱接缝只做 wrap argv:不 spawn、不 kill、不认识你的进程——接口越小,可复用的消费者越多(bash、pwsh、fs 全用同一份契约)。
  2. 失败必须有名字:denial 方言按后端隔离、runner 失败与命令被拦分开归因、无沙箱就 fail-closed——「沙箱坏了」与「命令被拦」是两种完全不同的失败,必须可区分。
  3. 无 root 沙箱的钥匙链模式:多候选靠功能探测仲裁、唯一候选免探测、全失败 fail-closed——探测的是「内核是否真执行」,不是版本号。
  4. 把模型当敌意对端:worker 端口逐字段重建、忙时计量预算、错误作为结果字段——运行时设计成不信任自己的输入,比更快的执行器更值得优先做。

总结

这一章拆完了沙箱与代码执行:wrap-argv 的极简接缝、300 行 C 的 Landlock 启动器、三平台的探测链、e2b 的远端迁移实证,以及把模型当敌意对端的代码运行时。执行世界到此闭环。下一章回到模型侧:LLM 接缝如何定义 block/chunk/finish 词汇,DeepSeek adapter 如何在「零决策」的传输层里把错误变成数据。