第三部分 · 能力接缝
第 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),只描述文件效果——网络与进程可见性不在词汇内,那是别的接缝的事。
无可用后端时抛 SandboxUnavailableError(SANDBOX_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 成对出现。执行前走 approveEscalation(sandbox/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.e2b(packages/e2b/e2b)是一个远程沙箱的生命周期所有者:构造即创建、准备 cwd 与私有 cwd/.dsh-e2b(校验非符号链接、chmod 700)、timeout/dispose 时 kill()。两个 adapter 共享同一个 SDK handle:
E2BFileSystem extends FileSystem:所有操作翻译成sandbox.files.*+sandbox.commands.run;canonicalPath用realpath -mz | base64 -w0把远程路径以 NUL 帧 base64 传回;写用「随机 staging 目录 + chmod 700 +rename/守卫式ln -T」原子提交;每 targetKey 一个withLock串行化。E2BSubprocessRuntime extends SubprocessRuntime:resolveExecutable用command -v、spawn 到远程状态目录(pid/exit-code/environment/stdout.log/stderr.log)、输出经 base64 帧流式回传、signalRemoteGroups用kill -TERM/-KILL杀远程进程组。
验证点:dsh-bash-local、dsh-terminal-bash、dsh-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 函数跨序列化边界必须
CodeJsonValue;PORTABLE_RESERVED_WORDS= ECMAScript ∪ Python 保留字并集——「一个后端合法的命名空间清单在一切后端合法」。
worker 线程是 containment 而非安全边界(信任姿态与 bash 等价);真正的系统边界是 ctx.sandbox 的 wrap-argv 接缝。dsh 没有把「代码执行」塞进沙箱接缝,也没有把沙箱塞进代码运行时——两个接缝各管各的,组合由消费者决定(bash-sandbox 可以把 bash 送进 Landlock,run_code 的 worker 则默认裸跑)。
实践应用
- 沙箱接缝只做 wrap argv:不 spawn、不 kill、不认识你的进程——接口越小,可复用的消费者越多(bash、pwsh、fs 全用同一份契约)。
- 失败必须有名字:denial 方言按后端隔离、runner 失败与命令被拦分开归因、无沙箱就 fail-closed——「沙箱坏了」与「命令被拦」是两种完全不同的失败,必须可区分。
- 无 root 沙箱的钥匙链模式:多候选靠功能探测仲裁、唯一候选免探测、全失败 fail-closed——探测的是「内核是否真执行」,不是版本号。
- 把模型当敌意对端:worker 端口逐字段重建、忙时计量预算、错误作为结果字段——运行时设计成不信任自己的输入,比更快的执行器更值得优先做。
总结
这一章拆完了沙箱与代码执行:wrap-argv 的极简接缝、300 行 C 的 Landlock 启动器、三平台的探测链、e2b 的远端迁移实证,以及把模型当敌意对端的代码运行时。执行世界到此闭环。下一章回到模型侧:LLM 接缝如何定义 block/chunk/finish 词汇,DeepSeek adapter 如何在「零决策」的传输层里把错误变成数据。