AI 生成的代码必须被执行,否则它就只是文本。但执行意味着风险。我没有选择容器隔离,也没有用 RestrictedPython,而是设计了一个三层防御:先在编译期用 AST 拒绝危险结构,再在运行时替换整个 builtins,最后用操作系统级资源限制兜底。三层各解决不同类别的风险,重叠但不冗余。

一个无法回避的矛盾

AI 编排系统的核心价值在于:AI 生成代码,系统执行代码,根据执行结果迭代优化。如果生成的代码不能被执行,整个循环就断了。

但执行 AI 生成的代码,和执行人类工程师写的代码,风险结构完全不同。人类工程师理解代码的副作用——他知道 import os; os.system('rm -rf /') 意味着什么。AI 没有这层认知。它的目标是"完成任务",如果直连数据库能更快地拿到数据,它就会尝试直连。不是因为恶意,是因为它不区分"合法手段"和"越权手段"。

所以问题不是"要不要沙箱",而是"沙箱应该防什么,在哪层防"。

问题的结构

把"AI 代码执行的风险"拆开看,实际上是三类不同的问题:

第一类是结构性危险——代码里包含了不应该出现的语法结构。import subprocesseval()__import__(),这些是在代码被解析时就能识别的模式。它们的危险性不依赖运行时上下文。

第二类是运行时越权——代码本身的语法没问题,但通过合法的 builtins 做了不该做的事。比如 open('/etc/passwd'),语法合法,但语义越权。或者通过 getattr() 链式访问 dunder 属性逃逸出沙箱。

第三类是资源滥用——代码逻辑正确、权限合规,但消耗的资源不可接受。无限循环、分配 10GB 内存的列表、CPU 跑满 30 分钟。这不是安全问题,是资源管理问题。

三类问题需要三层防御。试图用一层解决所有问题,要么过于宽松(漏掉某类风险),要么过于严格(正常代码也跑不了)。

我考虑过的方案

方案 A:容器隔离

Docker 容器或 WebAssembly 沙箱。每次执行启动一个隔离环境,代码在里面随便跑,跑完销毁。

这在多租户 SaaS 场景下是标准做法。Replit、CodeSandbox、各种在线 IDE 都用这种方案。安全性最高——操作系统级隔离,代码无论做什么都影响不了宿主。

为什么在 AI 迭代循环里不适用:延迟。AI 编排的核心循环是"生成 → 执行 → 评估 → 迭代",一次研究任务可能跑 10-20 轮迭代。每轮启动一个容器,加载 pandas、numpy,初始化数据上下文,执行代码,提取结果,销毁容器。冷启动开销在 2-5 秒,乘以 20 轮就是额外的 40-100 秒。对于一个需要快速迭代的研究系统,这个延迟不可接受。

还有一个更实际的问题:数据传递。容器内的代码需要访问行情数据,但数据不能复制进容器(太大),也不应该让容器直连数据库(违反架构原则)。需要一个序列化→传输→反序列化的通道,这本身就引入了复杂性和性能损耗。

方案 B:RestrictedPython

Python 社区有一个成熟的方案叫 RestrictedPython。它在字节码层面重写 Python 的编译过程,把所有属性访问、函数调用都替换成可拦截的代理函数。安全性介于"裸 exec"和"容器"之间。

为什么没采用:RestrictedPython 的设计目标是"在不信任的多租户环境里运行用户代码"——Zope/Plone CMS 时代的产物。它的安全模型非常严格,严格到 pandas 和 numpy 的很多操作都会被拦截。df.groupby() 触发属性访问拦截,np.array() 的内部 C 扩展调用绕过了 Python 层的限制。要让 RestrictedPython 和数据科学库共存,需要大量的白名单配置和 monkey-patching。

这本质上是一个适用场景不匹配的问题。RestrictedPython 假设代码来自不可信的外部用户。我的场景是代码来自受控的 AI 模型——风险存在但可预估,不需要字节码级的全面拦截,只需要拒绝已知的危险模式并限制资源消耗。

方案 C:三层防御——AST 扫描 + 运行时白名单 + 资源限制(我的选择)

设计哲学一句话:每层只负责一类风险,层之间正交,不试图用一层解决所有问题。

三层的具体设计

第一层:AST 静态扫描。在代码执行之前,把源代码解析成抽象语法树,遍历每个节点,检查是否包含禁止的结构。

禁止的 import:os, sys, subprocess, socket, shutil, ctypes, importlib...
禁止的调用:eval(), exec(), compile(), __import__(), open(), globals()...
禁止的属性:__subclasses__, __bases__, __globals__, __code__

AST 扫描的关键特性是确定性——同一段代码,扫描结果永远相同。不依赖运行时状态,不受输入数据影响。如果一段代码包含 import os,无论变量值是什么,AST 扫描都会拒绝它。

这层解决的是第一类风险:结构性危险。它是一个编译期的"预飞检查"——飞机还没起飞就发现发动机有问题,比空中发现便宜得多。

第二层:运行时 builtins 替换。不是在 Python 默认的 builtins 上做黑名单过滤,而是整个替换掉。

默认的 Python builtins 有 150+ 个函数和类。我的白名单只保留 53 个:数学运算(abs, min, max, sum, round)、类型创建(int, float, str, dict, list)、迭代(enumerate, filter, map, zip, range)、安全反射(isinstance, len, type)。

关键排除项:open()getattr()setattr()delattr()__import__()。这些是 Python 沙箱逃逸的经典路径——通过 getattr 链式访问 dunder 属性,可以从任何对象一路爬到 os.system

同时,安全的科学计算库通过预注入的方式提供。代码执行时,全局命名空间里已经有 pd(pandas)和 np(numpy),不需要 import。这避免了"为了让 AI 用 pandas 而不得不开放 import 权限"的两难。

第三层:操作系统级资源限制。用 POSIX 的 resource 模块设置硬上限:

内存:2048 MB(RLIMIT_AS)
CPU:120 秒(RLIMIT_CPU)
墙钟:300 秒(monotonic clock)
输出:50 MB

内存限制和 CPU 限制是操作系统内核强制执行的。代码分配超过 2GB 内存,内核直接杀进程——不是 Python 层面的 MemoryError,是 SIGKILL。这保证了即使前两层都被绕过,资源滥用也不会影响宿主。

墙钟超时用 time.monotonic() 而不是系统时钟,因为 monotonic 不受 NTP 校时影响,计时更可靠。

关键判断点

做出这个设计的核心判断是:代码来源是受控的 AI 模型,不是不可信的外部用户。

这个判断改变了安全模型的重心。面对不可信代码,你需要假设攻击者会主动寻找逃逸路径——字节码注入、C 扩展漏洞、竞态条件。面对 AI 生成的代码,主要风险是"无意越权"而非"刻意攻击"。AI 不会故意构造 "".__class__.__bases__[0].__subclasses__() 来逃逸沙箱,但它可能会尝试 import os 来读取文件,因为那是它在训练数据里见过的常见操作。

这意味着防御的重心应该放在"拒绝已知的危险模式"(AST 扫描)和"限制可用工具集"(白名单),而不是"防御未知的逃逸向量"(容器隔离)。前者更轻量、更快、对正常操作的干扰更小。

代码里有一行注释说明了这个设计定位:“Uses AST scanning + restricted globals to sandbox exec() calls. In production this should be replaced with container-based isolation for untrusted input.” 承认局限性,但明确当前场景下的合理性。

这个判断也来自一个跨领域的类比:机场安检。机场安检不检查每个乘客的每个细胞——它有金属探测器(对应 AST 扫描)、禁止携带物品清单(对应 builtins 白名单)、容量限制(对应资源限制)。三层各有盲区,但组合起来覆盖了绝大多数实际威胁。而如果你要把每个乘客关进隔离舱再运输(容器隔离),安全性确实更高,但航班永远不会准点。

实施效果

三层防御跑了几个月,处理了上千次 AI 生成的代码执行。AST 扫描拒绝率约 5%——主要是 AI 尝试 import 系统模块。运行时白名单拦截了零次越权——因为 AST 层已经过滤掉了绝大多数危险代码,白名单是兜底。资源限制触发了几十次——主要是 AI 生成的代码包含低效循环,超过了 CPU 时间限制。

每次执行的沙箱开销在毫秒级(AST 解析 + globals 构建),和容器方案的秒级冷启动相比,快了三个数量级。

这个决策教会我什么

我从这次实践中提炼了一条设计原则:

安全防御应该按风险类别分层,不应该试图用一层解决所有问题。每层只需要解决它擅长的那类风险,层之间的重叠是特性不是缺陷。

这条原则的适用范围不限于代码沙箱。网络安全的"纵深防御"是同一个思路——防火墙、入侵检测、应用层过滤各管一层。数据库安全也是——连接加密、SQL 注入过滤、行级权限各管一层。

关键认知是:每增加一层防御,投入产出比是递减的。第一层(AST 扫描)拦截了 95% 的风险,成本极低。第二层(白名单)拦截了 4% 的风险,成本中等。第三层(资源限制)拦截了 1% 的风险,成本最高。如果还要加第四层(容器隔离)来拦截最后 0.1%,成本可能超过前三层之和。

设计安全系统时,先问:剩余风险值多少钱?如果答案是"不值得再加一层",那当前的防御就够了。


系列第五篇。上一篇:终局思维:先想清楚怎么被审计,再决定怎么运行。第三篇:AI as Operator, Kernel as Law。第二篇:MCP 的问题不在协议层。第一篇:为什么我没有用多智能体架构