作者通过读取 /proc/[pid]/status 中的 Seccomp 字段验证 AI agent 框架的沙箱是否真正作用于正确的子进程,发现 NVIDIA NOOA 的网络隔离精确应用到执行代码的 worker 进程。
框架告诉我沙盒已启用。我想再确认一下,于是我去问内核。
$ grep Seccomp /proc/10920/status /proc/10922/status
/proc/10920/status:Seccomp: 0
/proc/10920/status:Seccomp_filters: 0
/proc/10922/status:Seccomp: 2
/proc/10922/status:Seccomp_filters: 1
10920 是 agent。10922 是它 fork 出来运行模型生成代码的工作进程。工作进程加载了 seccomp 过滤器,而父进程没有。,这就是网络阻断,只作用于应该受限的进程,不多不少。
这花了十秒钟,这是整个项目里我第一次拿"内核的报告"去验证,而不是听程序自己说。
我运行的 AI agent 框架会执行语言模型生成的代码。NVIDIA 的 NOOA 是我一直在研究的。它的文档格外直白:静态检查和拒绝列表是护栏,不是隔离边界,真正的边界在 OS 层。
它确实提供了这一层。每一段生成的代码都在一个 fork 出来的 worker 中运行,Landlock 限制文件系统,seccomp 阻断网络socket,资源上限,以及硬超时。其论文附录 D.2 描述了这个部署方式,包括其进程内 guard 自身的一个已知缺口,以及与之配套的兜底捕获机制。
所以问题不在于设计是否合理。它是合理的。问题在于论文里描述的那个东西是否真的在我机器上跑着。
execution_backend: Literal["inprocess", "sandbox"] = "inprocess"
OS 沙盒是可选的。我跑过的每一次 agent 运行,模型生成的 Python 代码都执行在 agent 自己的进程里,靠 AST 验证器和拒绝列表保护——文档已经明确告诉你这些不是隔离边界。
没有任何问题。我搭的 VM 确实在干活,这正是 README 说的。但我以为那一层已经在了,因为我读过它的源码,而读源码不等于检查实际运行的是什么。
把开关打开之后,这些 guard 变成了可验证的。只不过验证方式各不相同。
Seccomp 可以按进程读取。上面的差分就是证明,这是最强有力的一种证据:内核报告的是进程的情况,不是进程自己报告自己。
两个进程的 resource cap 都读出来是无限制。这看起来像是个发现,直到我读了配置:max_memory_mb 和 max_cpu_seconds 默认都是 0,意味着禁用。没有请求任何限制,所以没有应用任何限制。配置和内核的说法一致。我只是没读过配置。
Landlock 完全无法回读。一个进程一旦应用了规则集,限制就是真实且不可撤销的,但没有 /proc 字段可以查。差分技巧对它不适用。唯一能确认的方式是行为验证:让被限制的进程尝试读取允许路径之外的东西,然后看它是否失败。
这个值得停下来想一想。三个 guard 里,一个可以直接观察,一个设计上就不支持回读,一个只能靠行为演示。如果你想知道你的沙盒是否有效,"我配过了"对任何一个都不是答案。
我正准备写一个 Landlock 探针的时候,发现 NVIDIA 已经写好了。四十六个。
$ uv run pytest tests/runtime/sandbox/ -m integration -q
46 passed, 23 deselected in 22.48s
二十二秒,因为他们用的是假 LLM 客户端。不需要模型,不需要推理,不需要凭证。在这么短的时间内,隔离就变成了可测试的。
而且它们的写法正是你希望的那样。test_guards.py 里有 test_file_read_leak_without_sandbox 和 test_file_read_closed_with_sandbox。先测泄漏,再测关闭。内存和网络也一样。他们不接受一个通过的结果,除非先证明同一件事在 guard 关闭时会失败。
这就是我在整篇文章里倡导的那种纪律,现在就躺在我要研究这个项目的测试套件里,应用在每一条 guardrail 上。
然后我检查了它们是否在 CI 中运行
run: uv run pytest -q -m "not integration and not stress"
这是 ci.yml 第 38 行,是整个 workflow 目录里唯一的 pytest 调用。四十六条隔离测试都带 integration 标记。没有任何一条在 CI 里执行。
这个排除不是粗心。十二个测试文件带有那个标记,其中六个是需要 API 凭证的真实 provider 测试,根本不可能在 CI 里跑。标记对那组来说意思是"需要凭证",对沙盒这组来说意思是"会 fork 一个真实的 worker",一个过滤器同时拦住了两类。
我检查了最明显的辩护:也许沙盒测试在没有 Landlock 或 seccomp 的 runner 上会失败。它们不会。文件中每个 SandboxConfig 都传了 require=False,而需要特定机制的那四个测试带有 skip 条件。在没有这些特性的内核上它们会跳过而不是失败。
所以:一个能用的隔离测试套件,写得没问题,有配对的负向对照,却从来没有自动运行过。不是 guard 坏了,是没人看着它。
我把它提成了 issue #78。
在这一切进行的同时,我自己的验证脚本坏掉了两次。
六天前我写了第一版,它打印一个绿色的 OK,从结构上就不可能不打印。我修了它,加了一个检查,比较平台报告的完整配置键集合与已知良好的基线,这样一个重命名的键会机械地失败,而不是需要我注意到输出看起来短了一截。
然后它因为一个和漂移完全无关的原因失败了。
我是在 VM 运行的时候捕获的基线。一个运行的 VM 报告的键和关机状态的不同,所以跨状态比较把十个键标记成了重命名。十个失败,没有一个是真的。
我通过在基线里记录状态来修复,捕获新的基线,又失败了。四个键,还是 guest-report 的那些,它们在 guest 注册完设施之后大约一分钟才出现。我捕获的时候才过了三十秒。
三个版本,三次失败,全是同一类问题:检查和它实际运行环境之间的关系没有经过测试。它不能失败。在跨状态时错误触发。在同一状态内视时序而定也会错误触发。
抓住了第二个的那个东西,是我不会预测到的。一小时前我写了一个重建脚本,它的整个设计思路是让静默一个失败变得代价高昂:没有 force 标志,没有非交互模式,任何你删除的键都得手工重新输入。我设计它是让自己不可能悄悄删掉一个真实的失败。它的第一个动作是阻止我悄悄删掉一个假的。
同一周,一个缺失的 API key 让我浪费了一个下午。
错误说的是 InternalServerError。五百。于是重试了三次,那句有用的话埋在了两百行 traceback 的最底部。
链条是这样的:OpenAI SDK 在客户端构造时就抛出异常,发生在任何 HTTP 请求之前,所以它的异常不携带状态码。litellm 的 handler 把缺失的状态码默认成了 500。映射器看到 500 就把它当作服务器错误。
但缺失的状态码意味着没有发生任何 HTTP 交换。默认成 500 是在断言服务器返回了服务器错误。没有任何东西响应,也没有任何东西被请求。
和这里所有事情一样的形状:一层自信地报告它没有立场知道的事情。提交成了 litellm #35860。
这个栈里的每一层都在自我报告,每一层报告的价值都和它出错的能力精确相关。
框架说沙盒已应用。它报告的是它问过了。测试套件说绿色。它报告的是跑了的测试,不是被过滤掉的那些。我的脚本说配置匹配。它报告的是它想到要去看的那些键,在它恰好被告知的那个状态里。
没有一个是谎言。它们都比你听到的要狭隘得多。
有用的问题不是"它说没事了吗"。而是"它说这话的前提是什么,其中有没有任何一项是被自身之外的东西检查过的"。
有时候答案就摆在那儿。内核知道哪个进程有 seccomp 过滤器。内容寻址存储的文件名就是校验和。测试套件知道自己跳过了哪些测试。这些都不需要信任你要检查的那个东西。
有时候没有,比如 Landlock,那么唯一诚实的做法是故意把东西弄坏,然后看会发生什么。
ps -eo pid,ppid,comm | grep python # 找到父进程和 fork 出来的 worker
grep Seccomp /proc/<parent>/status /proc/<worker>/status
grep -E "Max address space|Max cpu time" /proc/<worker>/limits
Worker 上有过滤器而父进程没有,就是 guard 在干活。两者值相同意味着 guard 不在你以为的地方。
为什么 resource cap 读出来是无限制。它们默认是禁用的,这对于一个不可能知道你工作负载的框架来说是合理的选择。确实意味着一个新的沙盒阻断了网络、限制了文件系统,但不限制内存和 CPU,除非你主动请求。
脚本。VM 设置、验证器、还有重建工具都在 ai-security-lab,带着它所有的 bug。提交历史里有三次失败。