
字节笔记本
2026年10月6日 · 约 6 分钟读完
Landlock 误报复盘:ripgrep 没搜到也算错
在 Linux 沙箱体系里,Landlock 是一套内核级的访问控制机制,DeepSeek Harness 用它给 agent 的子进程加文件系统围栏。这起事故里,围栏没有被突破,命令也在约束下正常跑完,真正出问题的是 harness 对「谁失败了」的判断:内核打印的一条无害提示被当成了故障签名,ripgrep 只是没搜到结果,就被判定为沙箱不可用。
契约:同一个前缀,两种含义
原生 launcher 对 stderr 的约定区分两类输出。当内核较旧、只支持部分 Landlock ABI 时,launcher 会精确打印一行 landlock-run: partial enforcement (older Landlock ABI),然后照常执行子进程,这行字是纯粹的信息性通知。而 launcher 自身失败时,会打印另一行 landlock-run: 开头的诊断信息,并以退出码 125 结束,子进程根本不会被执行。
问题出在,沙箱提供方把这套约定压缩成了一个大小写不敏感的子串:runnerFailureSignatures: ['landlock-run: ']。消费方的判断是:进程以非零码退出,且 stderr 带这个子串,就判定为 runner 失败。
这个判定把两个来自不同进程的事实拼在一起:launcher 的信息性行,加上子进程自己的退出码。于是各种完全正常的非零退出都被算到沙箱头上:false 的退出码 1,ripgrep 无匹配的退出码 1,正则非法的退出码 2,甚至子进程自己选的 125。约束和执行其实都成功了,结果却被贴上 SANDBOX_UNAVAILABLE 的标签。
影响面与第二处归因错误
在只能部分强制执行的 Landlock 主机上,glob 和 grep 工具首当其冲,因为 ripgrep 把「搜索成功但零匹配」编码为退出码 1。用户看到的是沙箱基础设施故障,实际只是一次空搜索。
更麻烦的是当时的文件系统搜索实现。它由沙箱化 bash 支撑,runRipgrep() 会捕获 bash 执行器抛出的几乎所有错误,统一替换成一条关于 cwd 或 shell 启动的笼统 SEARCH_FAILED。这意味着沙箱真的故障时,执行器抛出的结构化 SandboxUnavailableError 也会被吞掉,调用方拿到的是错误的启动诊断,准确的错误码不翼而飞。
需要强调,这次缺陷没有削弱沙箱约束,也没有命令在无围栏状态下运行。它的安全影响集中在可用性和诊断完整性:有效的受限执行结果被拒绝,或者被错误标注。

根因:类型表达力与测试矩阵互相映照
根因层面,公开的沙箱结果类型只能表达「一袋子子串」。它无法表达三件事:Landlock 失败必须以退出码 125 为前提;证据必须落在单独一行致命诊断里;同一前缀下有一行精确文本属于信息性通知。于是布尔判断只能把不相关的事实连接起来,甚至在致命证据位于后续行时,仍然挑 stderr 的第一行当错误详情。
测试矩阵和缺陷互为镜像。模拟提供方要么不输出 runner 行,要么输出一目了然的致命前缀,从不在子进程控制的非零退出之前输出一条无害通知。真实 Landlock 覆盖依赖宿主内核的 ABI 版本:没有可用内核时测试会自行跳过,完整 ABI 的主机又触发不了通知,这条路径始终没被演练。文件系统搜索的测试只模拟了原始 spawn 错误,没覆盖真实沙箱 bash 组合抛出的结构化错误。发现过程也印证了这一点:一个打印通知后 exec 载荷的最小 POSIX 包装脚本,配合 false 和 ripgrep 空搜索两个场景,就稳定复现了故障。
修复:结构化规则加组装回放
修复分三层。第一层把签名升级为结构化规则:RunnerFailureRule 现在携带可选的允许退出码、大小写不敏感的逐行致命签名,以及按整行精确匹配排除的信息性行。对 Landlock 而言,失败要同时满足退出码 125 和一行非通知的 landlock-run: 诊断;bwrap、Seatbelt 和自定义 runner 维持纯签名判定。
第二层是统一分类逻辑。bash 沙箱包现在直接 spawn 提供方的 argv,启动前被拒时走 spawn 错误通道,而不是本地化 shell 诊断;前台与后台执行共用一个返回证据的分类器,致命证据优先于拒绝诊断,前台错误报告匹配到的致命行,同时保持捕获的 stderr 原样不动。
第三层是换掉出问题的接缝:文件系统搜索改为通过 ctx.subprocess 运行打包的 ripgrep,整个路径不再经过沙箱化 bash,SEARCH_FAILED 的遮蔽问题随之消失。回归覆盖双管齐下:原生边界有 partial-landlock.spec.ts 钉住信息性通知、致命证据和前后台分类;产品级路径由一份组装好的免密钥快照回放固定,不依赖文件系统搜索的实现细节。

边界与教训
修复没有宣布问题彻底终结。stderr 本质上是带内通道,受限的子进程完全可以故意复现 runner 的致命行和退出状态,制造新的误归因。更严格的合取条件挡住的是这次的意外碰撞,但无法验证写入者身份;彻底的解法是带外状态协议,那属于独立的加固工作,不是针对某种沙箱绕过的修补。
这次事故留下的经验可以浓缩成几句话。进程归因需要多项独立证据同时成立,共享前缀不是协议;信息性诊断和致命诊断可能共享同一命名空间,排除规则必须精确且狭窄,同时对未知致命行保持失败关闭;适配器必须保留下层接缝拥有的结构化失败,不能用自己最接近的通用类别把它替换掉;平台相关的行为,需要在原生边界放置确定性的假实现,再补一条组装后的产品路径,会自行跳过的真实内核测试撑不起回归防线。



