English version: Executing model-generated code: a two-layer sandbox。“LLM 应用工程”系列第 5 篇。
问题:为什么必须执行模型写的代码
自动报告场景里有一类内容绕不开代码执行:实验数据只有原始文件——.mat 扫场数据、csv 能带——而原有的图多是 MATLAB .fig,无法直接嵌入 PDF。LLM 读得懂数据格式,却画不了图:数据到图需要“计算 + 渲染”,这只能靠代码。于是设计与 Code Interpreter 相同:LLM 写一段 matplotlib 代码,后端在子进程里执行,产出 PNG,编译时嵌入报告。
在 agent 侧这是一个工具 make_figure,参数只有两个:{name, code}。agent 在工具循环里读懂数据后调用它;失败会收到报错,可改代码重试;成功后在报告里以 \includegraphics{用图/<name>} 引用。系统提示同时立规矩:有数据没图就画,不许伪造图片。
代价也很直接:这等于在后端执行一段由概率部件生成的任意 Python——一个内置的 RCE 入口。本文拆解为它设计的两层沙箱,以及实测结果。
威胁模型:模型分不清数据与指令
攻击链是清晰的:不可信的数据文件夹 → 文件内容进入提示词 → 提示注入 → LLM 写出恶意代码 → 后端执行。根因在于 LLM 在同一条 token 流里处理数据与指令(in-band signaling),与 SQL 注入(数据混入查询)、缓冲区溢出(数据混入控制流)同构。这类问题在模型层无法根治:对齐训练能降低概率,给不出保证。业界的共识路径因此是转向“假设注入会成功,控制损害”。
工程推论:安全设计不能依赖“模型不会写坏代码”这个假设,必须在代码离开模型之后、产生任何效果之前设关卡。我们设了两层:执行前静态拦截与运行时隔离——第一层负责把已知危险模式挡在执行之前,第二层假设第一层会漏,把漏网代码能造成的损害压到最低。
第一层:AST 白名单——执行前静态拦截
ast_check 把模型代码 parse 成 AST(解析失败即以语法错误拒绝),遍历全部节点,任一违规即拒:
- import 白名单(17 个模块):只允许 numpy/scipy/matplotlib/pandas/math/csv/json 等数据绘图模块,
import与from ... import都按顶层包名比对。立场是白名单而非黑名单——判断依据是“绘图任务需要什么”,不是“攻击者会用什么”。 - 危险名黑名单(37 个名字,查裸名而不只查导入):os/sys/subprocess/socket/ctypes/importlib/pickle/pathlib/glob……出现即拒。这一条不冗余:执行包装层为实现文件导航预导入了
os,模型代码不写import os也能直接调os.remove——所以必须拦“使用”,不能只拦“导入”。 - 危险内置(17 个):eval/exec/compile/
__import__/open/getattr/globals……既禁调用也禁裸名引用,e = eval这类别名同样被拒。 - dunder 逃逸:任何
__xx__属性访问一律拒绝,堵住().__class__.__subclasses__()这条经典逃逸链:
elif isinstance(node, ast.Attribute):
if node.attr.startswith("__") and node.attr.endswith("__"):
return False, f"禁止访问 {node.attr}"
拒绝不是终点。返回给 agent 的报错是教学式的:写明只能用哪些库、用 srcpath('文件名') 取数据、用 plt.savefig(OUT) 保存、禁了什么。agent 拿到这条确定性信号后改写重试——与编译自修复是同一个闭环模式:把确定性检查的输出回注给模型。
第二层:运行时隔离——漏网之后无损可造
第一层是文本层面的静态分析,理论上可能漏。第二层假设已经漏了,把执行环境削到最小:
| 措施 | 针对的损害 |
|---|---|
| 环境从零构建:只带 PATH/MPLBACKEND/MPLCONFIGDIR/HOME/LANG 白名单变量 | API 密钥、代理配置自动出局,不随环境泄漏 |
_GUARD 序言注入在用户代码之前:os.system 换成抛 PermissionError 的 lambda、sys.modules['subprocess'] 置 None、socket.socket 禁用 | 静态漏网后的命令执行与外联 |
隔离 cwd:每次执行 mkdtemp 一个空目录,跑完即删 | 落盘污染宿主目录 |
python -I:isolated 模式 | PYTHONPATH / 用户 site-packages 注入 |
| 90 秒超时 | 死循环与资源耗尽 |
| 源数据目录由 compose 只读挂载(容器部署形态) | 篡改源数据 |
从零构建环境有平台代价:Windows 上 CPython 子进程启动需要 SystemRoot 等系统变量(加密 RNG、系统 DLL),matplotlib 需要可写的 MPLCONFIGDIR/TEMP——这些必须显式回填,否则出图在 Windows 分发包上必然失败。
还有一个容易被忽略的配套设计:禁用与替代必须成对出现。白名单禁了 os/glob/pathlib,模型就没法列目录、拼路径——不给替代品,等于逼模型违规。所以包装层注入三个导航原语,全部限定在源数据目录 SRC 之内:srcpath('相对名') 取路径、listdir(sub='') 列目录、walkfiles() 递归列出全部相对文件名;工具说明里同步写清约束与用法,让模型首次就写出合规代码。失败时也只回传 stderr 尾部 1500 字符——报错同样是 token。
从零环境 · 隔离 cwd · python -I · 90s 超时"] R -->|"OUT 存在且非空"| P["PNG 入会话图目录,编译时嵌入"] R -->|失败或超时| E
实测:七类恶意样本全部执行前拦截
容器内实跑验证。正当路径两条通过:基本 matplotlib 绘图、经 srcpath 读真实数据绘图。恶意样本七类,全部在执行前被 AST 层拒绝:
| 恶意样本 | 命中规则 |
|---|---|
os.system 执行命令 | 危险名黑名单(os 为预导入,拦裸名而非 import) |
os.remove 删文件(不 import,直接用预导入的 os) | 同上 |
import socket 外联 | import 白名单 |
eval 动态执行 | 危险内置 |
__import__ 绕过 import 语句 | 危险内置 |
dunder 逃逸(__class__/__subclasses__) | dunder 属性拦截 |
open 读 /etc/passwd | 危险内置 |
第二层在这组样本里没有被触达——这正是预期:兜底层的价值在未知样本,不在测试集。
两个意外收益:安全约束反哺打包
单机分发时后端用 PyInstaller 冻结为二进制。两处与沙箱的交互原本可能是深坑,白名单反而把它们变浅了。
**收益一:可 import 集有限已知,collect_all 精确堵坑。**PyInstaller 靠静态分析收集依赖,而沙箱里的绘图代码是运行时才存在的字符串——它 import 什么,打包器看不见。若可 import 集无界,这个问题无解;但 AST 白名单恰好把它限定为有限已知集合,对 numpy/scipy/matplotlib 等整体 collect_all 即可收入。Windows 实测:冻结产物 onedir 约 260MB(exe 30MB),selftest 结果 imports 10/10、savefig 18250 字节——科学栈在冻结包内完全可用。
**收益二:冻结态 sys.executable 的自我分流。**开发态的执行命令是 [sys.executable, "-I", 脚本];冻结后 sys.executable 是本二进制而非 python,直接 exe -I script 会被当成服务启动而失败。解法:run_plot 检测到 is_compiled() 后改为设置环境变量 PDFAGENT_PYRUN=<脚本路径>、再起一个本 exe;入口 run_server._pyrun() 发现该变量即用 runpy.run_path 执行脚本并退出,不启动服务。进程隔离、清洗环境、超时、AST 白名单、_GUARD 全部保留,仅少了 -I 标志。这条分流由 test_pyrun.py 的两个用例(有变量则执行脚本、无变量则空转)钉死。
适用边界
- **这不是完整沙箱。**无 microVM、无容器级隔离,子进程与宿主同内核、同文件系统权限——AST 被绕过且
_GUARD被规避的组合攻击在理论上存在。项目对此的定位写在模块 docstring 里:面向本地单用户场景(执行的是用户本机自己数据上的绘图代码,等同于用户手动跑脚本),两层已足够;若输入是“不可信文件夹/多用户”,根治要上“一次性无网络容器执行”。 - **白名单牺牲表达力。**getattr/open 被禁;h5py 等不在白名单,遇到 HDF5 类数据需要显式扩充白名单,并重新评估新库的能力面(能否发网络请求、写文件)。白名单每宽一格,第一层的保证就弱一分。
- **域限定是前提。**此方案可行的根本原因是任务域窄:“数据绘图”所需的模块集小且稳定。若要执行的是通用代码——agent 自由写任意工具脚本——白名单会宽到失去意义,应直接上容器/microVM 级隔离,AST 层最多做启发式预检。
- **第二层与平台细节耦合。**从零构建环境意味着平台的隐式依赖要自己踩全:Windows 的系统变量、matplotlib 的可写配置目录。跨平台分发的项目要为此准备实测验证,而不是推理验证。