Landlock 部分强制执行被误判:0004 号事故与沙箱归因的真实边界

2026-08-17

先说清楚一件事:DeepSeek Harness 这个仓库的 README 第一屏就自述处于开发者预览阶段,并且用大写强调「会有破坏兼容性的变更」。我们下面提到的每一个字段名、每一个默认值、每一个文件位置,都是快照 47f9438(版本 0.1.0-rc.5)里读到的样子,随时可能变。我们没有安装、没有运行过它,所有结论都来自源码与文档文本本身。

一条无害的提示行,让 grep「没搜到」变成了「沙箱坏了」

docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md 记的是这么一件事:在 Landlock ABI 较旧的内核上,launcher 每次执行子进程前都会往 stderr 打一行提示;harness 把这行提示和「子进程非零退出」这两件互不相关的事组合起来,判定成 launcher 失败。于是 ripgrep 在没有匹配项时以 1 退出这种再普通不过的结果,就被报成了 SANDBOX_UNAVAILABLE

要理解为什么这条判定看着合理却是错的,得先看 launcher 自己的契约。原生 launcher 的源码在 native/landlock-run/packages/entry/src/main.c,里面那行是这么打的:

fprintf(stderr, "landlock-run: partial enforcement (older Landlock ABI)\n");

紧接着就是 execvp(cli.command[0], cli.command)。也就是说,这行打完,子进程照样跑,约束照样生效——只是老 ABI 管不到全部访问类型(源码注释举的例子是 ABI 3 之前的 truncate)。它的定位是「报告,不是拒绝」。

而 launcher 真正失败时是另一套动作。native/landlock-run/docs/cli-contract.md 写明:125(对应 LAUNCHER_FAILURE_EXIT)用于所有 launcher 级失败——用法错误、内核无法强制执行 Landlock、grant 根目录打不开、exec 失败,此时被包裹的命令根本没有跑;每次这类失败都会打一行以 landlock-run: 为前缀的 stderr。这两个常量在 native/landlock-run/packages/entry/src/index.ts 里导出,LAUNCHER_BIN'landlock-run'LAUNCHER_FAILURE_EXIT125

问题就在这里:成功时的提示行和失败时的诊断行共用同一个前缀。而契约文档里还额外补了一句关键的话——exec 成功之后,子进程的退出状态是原样透传的,包括 125。所以退出码 125 本身也不能证明是 launcher 失败。

事故当时的实现把这整套契约压缩成了一个子串。复盘的时间线一节写得很直白:沙箱提供方给出的是 runnerFailureSignatures: ['landlock-run: '],消费方把这个前缀和任意非零退出组合起来就报 runner 失败,并且拿 stderr 的第一行当详情。第一行恰恰是那条无害提示。

于是 false、ripgrep 无匹配的 1、无效 pattern 的 2、乃至子进程自己选的 125,在约束与执行都成功的情况下被算到了沙箱头上。复盘的「影响」一节还点了第二层遮蔽:当时由 bash 支撑的文件系统搜索会把沙箱执行器抛出的结构化错误替换成通用的 SEARCH_FAILED,调用方连 SANDBOX_UNAVAILABLE 这个码都拿不到。

有一点复盘写得很克制,我们照抄:这个缺陷没有削弱约束,也没有让命令在无约束状态下运行,它的安全影响在于可用性与诊断完整性——有效的受限结果被拒绝或被贴错标签。

修法:把「一袋子串」换成三个字段

现在的公共类型在 packages/sandbox/sandbox/src/index.tsRunnerFailureRule 长这样:

export interface RunnerFailureRule {
  /** Nonzero process exit codes on which this rule may match; omitted permits any nonzero exit. */
  allowedExitCodes?: readonly number[]
  /** Non-empty substrings identifying a fatal runner diagnostic on one stderr line. */
  fatalSignatures: readonly string[]
  /** Benign stderr lines excluded by exact full-line equality before fatal matching. */
  informationalLines?: readonly string[]
}

三个字段各自对应契约里原先表达不出来的一件事:失败必须带 125、证据必须落在某一行里、同前缀下有一行精确文本属于信息性通知。同一份文件里紧挨着的注释还留了一句边界很硬的话:Exit status alone never proves runner failure(仅凭退出状态永远不能证明 runner 失败)。

判定逻辑在 packages/shell/bash-sandbox/src/helpers.tsclassifyRunnerFailure(),读的时候要注意它的顺序,顺序错了效果就不一样:

if (exitCode === null || exitCode === 0) return undefined
const lines = stderr.split(/\r?\n/)
for (const rule of rules) {
  if (rule.allowedExitCodes !== undefined && !rule.allowedExitCodes.includes(exitCode)) continue
  const informationalLines = new Set((rule.informationalLines ?? []).map(line => line.toLowerCase()))

四道关卡依次是:退出码为 0 或者进程是被信号打死的(exitCode === null)直接不算;规则若带 allowedExitCodes,退出码不在里面就跳过这条规则;把 informationalLines 转小写做整行精确相等排除;剩下的行才拿去逐个匹配 fatalSignatures 的子串。还有一处容易被忽略的防御:fatalSignatures 会先被 signature.trim().length > 0 过滤掉空白项——注释写明空串或纯空白不构成有意义的 runner 证据,但同一条规则里其它有效签名照常生效。

命中之后返回的是 { detail: line },即匹配到的那一行,不是 stderr 的第一行。packages/shell/bash-sandbox/src/index.tsrun() 里也留了注释说明这一点:runner 失败优先于 denial,因为命令根本没跑;携带的是匹配到的致命行,而不是它前面那条信息行。

这里有一个按代码字面就能读出来的细节,配自定义 runner 的人值得留意:排除用的是 informationalLines.has(lowered),没有 trim()。一行只要在整行相等这一步对不上(哪怕只差一个尾随空格),就会落回子串匹配那一步。复盘的「教训」一节自述过这个取向:排除规则必须精确且范围狭窄,同时对未知的致命行保持失败关闭

哪些后端被退出码门控,哪些至今只看签名

具体的映射表在 packages/sandbox/sandbox-local/src/index.tsRUNNER_FAILURE_RULES,这张表值得逐行看,因为四个后端待遇并不一样:

后端allowedExitCodesfatalSignaturesinformationalLines
landlock[125]landlock-run: 那条 partial 通知全文
windows-acl[127]windows-acl-run:
bwrapbwrap:
seatbeltsandbox-exec:

127 这个数在同一个文件里以 WINDOWS_ACL_RUNNER_FAILURE_EXIT 常量出现,注释里明确说这是 windows-acl runner 自己的失败退出契约,与 Landlock 的 125 不是一回事。同一段注释还解释了 bwrap 与 Seatbelt 为什么留在「仅签名」状态(文档自述):bwrap 当前的致命路径虽然以 1 退出,但它的公开契约并没有保留这个状态码;sandbox-exec 则根本没有公布 launcher 失败状态码。

Windows 侧还有一处对称结构:packages/shell/pwsh-sandbox/src/helpers.ts 里有一个同名同逻辑的 classifyRunnerFailure(),判定顺序与 bash 侧一致。用 pwsh 执行器的读者排查时得看这一份,别只盯着 bash 那份。

自定义 runner 是这条路上最需要小心的一格。同一文件里,配了 runnerCommand 时构造出来的规则是 runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }]——没有退出码门控,也没有信息行排除。换句话说,0004 号事故那种「共享前缀 + 任意非零退出」的形状,在自定义 runner 这条路径上仍然是当前的判定方式。配置层只做了三条静态校验:runnerFailureSignatures 不能脱离 runnerCommand 单独出现、配了 runnerCommand 就必须至少给一条签名、每条签名必须是非空的单行字符串。另外这个 provider 的 probeTimeoutMs 默认值是 5_000,注释说明零会被 Node 当成「无超时」,所以做了正数校验——这是配置默认值,不是任何运行表现的承诺。

文件系统搜索为什么不再走沙箱 bash

事故里第二层遮蔽的处置方式是把路径挪开。现在 packages/fs/tool-fs-search/src/search-core.ts 直接通过 ctx.subprocess spawn 打包在 npm 包里的 ripgrep(@vscode/ripgreprgPath),不经过沙箱化 bash,也没有 shell 引号问题。退出码处理写得很直:

if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
  throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy)
}
return { stdout: text, noMatches: outcome.exitCode === 1, workdir }

0 和 1 都算正常返回,1 被翻译成 noMatches——这正是当年被误报的那种情况。

这里有一处口径差异值得单独标出来。复盘正文把「无效 pattern 的退出码 2」列为当年可能被误归因的一种情况;而当前 classifyRunFailure() 判断 SEARCH_INVALID_PATTERN 靠的是对 stderr 文本做 /regex parse error|error parsing glob/i 匹配,并不是靠退出码 2,匹配不上就落到 SEARCH_FAILED。两处口径不同,以我们实读的源码为准。我们不替作者解释原因,说完这一句就停。

怎么判断自己是不是撞上了这一类问题

先说判定动作。看到工具报错时,第一件事是看结构化错误码是不是 SANDBOX_UNAVAILABLE——这个常量就定义在 packages/sandbox/sandbox/src/index.tsSandboxUnavailableError 的消息里会以 Runner failure: 引出携带的那一行。拿到那一行之后对照上面那张表:它是不是你当前后端的致命前缀?退出码是不是该后端契约里的那个数(Landlock 是 125,windows-acl 是 127)?如果携带的详情行恰好就是那条 partial 通知全文,那说明信息行排除这一步没生效。

其次是看 enforcement。这个字段的语义在 docs/subsystems/sandbox.md 里写明:full 表示后端管住了该模式承诺的每一项文件效果,partial 表示活动后端或较旧内核 ABI 只管得住一个子集;文档把「较旧的 Landlock ABI」和「Windows ACL runner 的 Everyone/硬链接边界」列为当前的两类 partial 情形。需要绝对边界的调用方不能把 partialfull 用。

什么情况说明不是这个原因,这一步别省:

  • 错误码根本不是 SANDBOX_UNAVAILABLE,而是 SEARCH_FAILED / SEARCH_INVALID_PATTERN / SEARCH_ABORTED——那是文件系统搜索自己的错误词汇表,那条路径现在压根不过沙箱 bash。
  • stderr 里没有任何 landlock-run: 开头的行——按 classifyRunnerFailure() 的写法,没有匹配到致命行就不会返回 runner 失败,退出码再怪也不会被算成沙箱故障。
  • 进程是被信号打死的:exitCode === null 在第一行就被挡掉了,那属于另一类问题。
  • 你根本没走本地 runner 这条链(比如换了别的 capability seam)。packages/sandbox/sandbox/src/index.ts 的模块注释写明这个 service 管的是「同一世界内的进程约束」,共享宿主内核与文件系统;容器、microVM、远程执行是替换掉外层 seam,不在这套判定里。

这份复盘真正的边界在哪

最后必须原样转述复盘「根因」一节里那段最要命的话,因为它划定了这次修复没有解决什么:stderr 仍然是带内归因通道;一个受限的子进程完全可以故意复现 runner 那条被门控的致命诊断行和退出状态,制造可用性或诊断上的误归因。更严格的多项证据合取能避免本次事故中的意外冲突,但无法验证写入者身份;带外状态协议属于另一项独立的加固工作,而不是沙箱绕过的修复。

所以别把这次修复读成「归因现在可信了」。它把「一个共享前缀」升级成了「退出码门控 + 逐行致命签名 + 精确信息行排除」这样一个合取条件,覆盖的是意外碰撞;对刻意伪造,源码与文档都没有给出任何保证。同理,dsh 这类工具会在本机起子进程、跑 shell,「有沙箱」本身不构成安全结论——SandboxMode 的注释也写明这套词汇只管文件效果,网络与进程可见性不在这套词汇表里

回归怎么钉住的也值得看一眼,因为它回答了「为什么以前测不出来」。复盘自述当时的真实 runner 测试在没有可用内核时会自行跳过,用完整 ABI 的主机根本触发不到那条通知。现在 packages/shell/bash-sandbox/tests/partial-landlock.spec.ts 用一个 /bin/sh 假 launcher 在原生边界做确定性模拟:它打印那条通知,然后 exec "$@"。这个文件里有 11 个测试块,其中 5 个是 it.each 参数化,展开后共 19 个用例,覆盖了子进程退出 0/1/2/125、126/127、通知后跟致命行、denial 文本、以及前台与后台两条分类路径。组装后的产品路径则由 examples/acp-agent/partial-landlock.cordis.snapshot.yml 与快照目录 partial-landlock-child-failure 固定,配套的假提供方在 examples/acp-agent/tests/fixtures/partial-landlock-sandbox.ts——那个文件的注释直接要求把 Landlock 那组值与 sandbox-local 里的 RUNNER_FAILURE_RULES 保持一致。要改这几个数,得记住有两处需要同步。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。