安全与平庸:agent 沙箱轮子的真实取舍
给 agent 选择代码沙箱,主要矛盾是安全与运行时兼容性。完整的标准库、常见的语言特性和符合直觉的 I/O 行为看起来普通,却正好位于 LLM 的训练分布内。模型默认 import os 可用、console.log 有输出、requests.get 会返回结果。沙箱每删掉一项常规能力,都要在 prompt 中补充说明,并承担报错和重试的成本。
本文把这种兼容性称为“平庸”。它不是贬义词,而是衡量 LLM 能否按已有经验写代码。下面比较 JavaScript 和 Python 生态中的几种实现,重点看隔离模型、公开的安全记录、中断与资源限制、LLM 常见错误,以及状态持久化。
大厂怎么选
先看已经投入使用的方案:
| 谁 | 场景 | 选择 | 主要取舍 |
|---|---|---|---|
| OpenAI | Programmatic Tool Calling | 托管的全新 V8,每程序一个 | 无 Node、npm、console、网络和持久状态 |
| OpenAI | Codex CLI(macOS) | Seatbelt(sandbox-exec)包住工具调用 |
保留完整运行时,由 OS 提供边界 |
| Cloudflare | Code Mode | Dynamic Worker V8 isolate | 毫秒启动,禁 fetch,工具经预授权 binding 注入 |
| Anthropic | Code execution with MCP | 文件系统 + TS 文件 | 给出设计,沙箱实现留给使用者 |
| Figma | 第三方 plugin | QuickJS 编译成 WASM | 接受性能损失,换取结构隔离 |
| Shopify | Functions | Javy(QuickJS→WASM) | 模块 ≤256KB、执行 ≤5ms |
| MetaMask | Snaps / 供应链 | SES + LavaMoat | 冻结语言环境,只保证完整性 |
| E2B / Modal | 通用代码执行 | Firecracker / gVisor microVM | 保留原生运行时,由内核边界隔离 |
这些方案基本分成两类:限制运行时能力,或者保留完整运行时并把隔离放到进程、操作系统或虚拟机。试图在同一个完整语言运行时内部实现强安全边界的方案,历史记录并不好。
JavaScript
Node 进程内隔离的问题
vm2 曾是 npm 上最流行的 JavaScript 沙箱。它运行在 Node 自己的 VM 中,通过 Proxy 限制沙箱内代码访问宿主对象。二十多次逃逸之后,维护者在 2023 年宣布停维护,理由是项目包含关键安全问题,不应再用于生产。项目在 2025 年 10 月短暂恢复维护,随后又出现一个 CVSS 9.8 漏洞。
vm2 建在 node:vm 之上,而 Node 文档明确说明:node:vm 不是安全机制,不应运行不可信代码。问题不只在 vm2 的具体实现,也在于 Node 从未把同进程内的攻击者代码作为安全边界来设计。
Figma 的迁移提供了一个直接对照。它最初用 Realms shim 隔离 plugin,复用浏览器的 JavaScript 引擎,性能和兼容性都很好。但沙箱内外的对象仍在同一个 VM 中,对象身份混淆导致了多次逃逸。迁移到 QuickJS-WASM 后,这类漏洞因两侧对象表示不同而消失。后来 Realms 的漏洞虽然修复,Figma 也没有迁回,而是继续接受 QuickJS 较慢的代价。
V8 isolate 不是最终边界
isolated-vm 使用真正的 V8 Isolate 分离堆,是 vm2 README 推荐的替代方案,Screeps、Fly.io 和 Algolia 都使用过它。它的 README 同时警告,使用者如果缺少安全经验,很容易犯下严重错误。项目目前处于维护模式。
2026 年 8 月披露的关键逃逸利用 ExternalCopy 类型混淆,通过一个 Reference 完成 guest-to-host 逃逸,影响 n8n、Activepieces、Mastra、Budibase 等 agent 产品。这也说明 isolate 不能单独承担最终安全边界。V8 官方数据显示,过去三年被野外利用的 Chrome 漏洞中六成发生在 V8;Cloudflare 也在 Workers 安全模型中承认 isolate 无法阻止 Spectre。2026 年 8 月出现 12 bits/秒的生产环境跨租户泄漏演示后,Cloudflare 最终使用进程隔离和 MPK 缓解。
QuickJS-WASM 与 SES
quickjs-emscripten 把 QuickJS 编译到 WASM。计算密集负载下,它通常比 V8 慢 10–50 倍,只覆盖部分 ES 特性;异步需要 asyncify,且会让体积翻倍。Cloudflare 的判断是,典型 LLM 脚本大部分时间都在等待外部工具调用,因此解释器性能差距对这类 I/O-bound 负载影响有限。
QuickJS 本身仍然出现过堆溢出、UAF 等内存漏洞,WASM 线性内存的作用是把内存破坏限制在边界内,而不是消除解释器漏洞。
SES 选择语言层约束:冻结 intrinsics,并按 capability 注入权限。MetaMask 用它保护三千万用户面对的供应链风险。但 SES 只承诺完整性,不处理无限 CPU 或内存消耗;冻结原型也会破坏一部分第三方库。它解决的问题比完整沙箱更窄。
中断和资源限制
JavaScript 侧已有多种执行中断原语:QuickJS 的 setInterruptHandler 会在执行期间定期回调,可以终止 while(true){};V8 提供 TerminateExecution;wasmtime 提供 epoch interruption,并保证 guest 无法绕过 deadline 检查。三者都有同一个限制:代码阻塞在原生调用或宿主函数中时,只能等待调用返回。
资源限制不能只看 API 是否存在。Simon Willison 的横评中,Node worker_threads 的 resourceLimits 设置为 4MB 后,进程实际使用到 88MB 仍未终止;isolated-vm 和 QuickJS-WASM 的限制则确实生效。
运行时能力被删减后,LLM 的常见错误也很稳定:默认使用 console.log、fetch 或 require,假设 npm 存在,或者在只允许同步执行的环境里写 async。通常需要把能力清单和注入函数的类型签名放进 prompt,再把原始错误交给模型修正。
宿主 API 仍是重要的攻击面。Check Point 在 When Agentic Glue Melts(Black Hat 2026)中披露了 workerd 的五个内存破坏漏洞:攻击从 prompt injection 开始,利用 node:zlib 的 use-after-free 逃出沙箱并在宿主执行原生代码。node:zlib 正是为了提高兼容性而加入的 Node API。
Python
Monty:安全的 Python 子集
Pydantic 的 Monty 是用 Rust 编写的极简 Python 解释器,启动时间为微秒级。文件系统、网络和环境变量只能通过宿主注入的函数访问,隔离方式接近 Python 版 QuickJS。
它的兼容性限制也很明显:只实现 Python 子集,标准库只有个位数模块,发布时连 class 都不支持。支持者认为 LLM 收到报错后可以改写代码;反对者认为,模型会把推理资源花在绕过解释器限制上。HN 最高赞的批评把它概括为“没有标准库的安全版 eval()”。Pydantic 公开征集 LLM 所需能力,说明 Monty 仍在补齐兼容性。
Monty 已经办过三轮 Hack Monty 攻防赛。第一轮开始 48 小时内,攻击者用两个 GC bug 组成 use-after-free 完成逃逸,而且漏洞由攻击方的 AI agent 发现;后两轮无人逃出,第三轮悬赏提高到两万美元。
其他进程内方案
Pyodide 提供完整 CPython 语义并能运行 numpy,但 LLM 常用的行为并不完整:time.sleep 是空操作,requests 不可用,文件系统是虚拟的,冷启动以秒计。langchain-sandbox 曾使用这条路线,现在仓库已标记为不再维护,并明确不推荐生产使用。
RustPython 比 Monty 覆盖更多语言特性,但项目 RFC 承认它目前不适合安全嵌入:无限循环无法终止,内存分配没有上限。实际使用通常是把它作为 WASI 程序运行,由 wasmtime 提供资源限制。MicroPython 在 agent 沙箱中没有形成可见的使用案例。更早的 pysandbox 已被作者放弃,RestrictedPython 和 audit hook 也有绕过记录;CPython 的自省能力让进程内隔离很难完整实现。
中断与持久状态
Monty 有三层被动限制:解释器累计时长预算、宿主端每轮硬超时,以及超时后杀掉子进程。但它没有 session.interrupt() 形式的主动取消 API。用户停止执行时只能关闭整个 session,持久 REPL 状态也随之丢失。
FastMCP 曾报告一个相关的线程泄漏问题:asyncio 任务取消后,沙箱原生线程仍以满 CPU 运行到进程退出。该 issue 因缺少最小复现而关闭。Monty 的预算检查点已经具备中断所需的部分机制,但尚未暴露主动 API。
完整的 IPython 体验要求持久命名空间、通过 OS 信号实现 Ctrl-C、依赖自省的富输出和补全,以及支持 C 扩展与真实文件系统的 !pip install。这些能力很难与进程内隔离同时保留。已投入使用的方案普遍选择 kernel-in-a-box:不修改 IPython,而是把整个 kernel 放进 microVM 或 OS 沙箱。OpenAI Code Interpreter、E2B(Firecracker,快照恢复 5–30ms)和 Modal(gVisor)都是这种结构,interrupt_kernel() 可以直接发送 SIGINT。
Codex CLI 是较轻的实现:macOS 上用一层 Seatbelt 配置包住完整原生工具链,默认拒绝访问,只开放允许写入的目录和可选网络。它隔离的是工具调用,而不是语言运行时。
为什么两个生态的选择不同
JavaScript 的中间方案在 LLM 普及前已经由 plugin 和边缘计算推动成熟。Figma 从 2019 年起运行第三方 plugin,Shopify 要在 5 毫秒内执行商家代码,Cloudflare 需要大规模快速启动隔离环境,MetaMask 则要防供应链攻击。因此 QuickJS-WASM、isolated-vm 和 SES 在成为 agent 沙箱之前,已经积累了生产使用记录。
Python 的同类方案直到 2025 年 Monty 出现才开始补齐,调研时 V1 尚未发布。此前主要选择是 Pyodide,或者把完整 Python 放进进程、容器和 microVM。
语言能力本身不是分歧的主要原因。研究项目更多选择 Python:CodeAct 和 smolagents用它验证代码行动相对 JSON 工具调用的优势,理由包括语言流行度、包生态,以及 LLM 编写 Python 略强的证据。生产 code mode 更多选择 JavaScript。2025 年 9 月到 11 月,Cloudflare、OpenAI、Anthropic 先后使用 JS/TS;现成的 V8、与 JSON 接近的字面量、MCP 的 TS SDK,以及从 schema 生成类型接口的能力,都降低了实现成本。
pi 生态同时展示了两种路线。Python 扩展 pi-codemcp、pi-code-tool 建在 Monty 上,并利用快照实现协作式取消:修改类工具调用时冻结脚本、等待审批,甚至在数天后从原位置恢复。JavaScript 扩展 runline 用 QuickJS-WASM 运行 agent 代码,把插件实现留在沙箱外的 Node 中;pi-codemode 则提供两个执行器,在 Node 环境使用 isolated-vm,无法安装原生模块时改用 QuickJS-WASM。
几个共同约束
第一,逃逸经常发生在宿主连接层。Monty 的问题来自 unsafe Rust 中的 GC bug,workerd 出在 C++ 绑定,isolated-vm 出在对象复制的类型混淆,Realms 出在同 VM 对象身份混淆。解释器之外暴露多少宿主 API,和解释器本身同样值得审查。
第二,语言级限制不能独立解决可用性问题。无限循环、内存耗尽和交互式中断仍需要 WASM 的 epoch/fuel,或者进程与虚拟机边界。
第三,完整 REPL 与强进程内隔离很难兼得。需要原生生态、持久状态和可靠中断时,重复出现的实现都是完整 kernel 加外部沙箱。
如何选
- 编排型 code mode:LLM 只写几十行代码串联工具调用,程序短、无状态且主要等待 I/O。JavaScript 可选 QuickJS-WASM;如果选择 isolated-vm,需要在外层增加安全边界。Python 可选 Monty,但要接受语言子集和缺少主动中断。
- 数据分析和长会话:需要完整包生态、持久状态与 IPython 交互时,使用 kernel-in-a-box。隔离强度按威胁模型选择 Seatbelt/bubblewrap、容器或 microVM。
- 所有方案:把中断和资源限制纳入验收,并实测限制是否生效;审查所有宿主 API;语言层沙箱之外再增加进程或虚拟机边界。
LLM 对标准运行时的行为最熟悉,因此兼容性会直接影响生成代码的可靠性。强安全也不必全部由语言运行时内部提供。现有方案更常见的做法,是保留需要的语言能力,再用 WASM、进程、操作系统或虚拟机建立边界。