# 执行模型生成的代码：两层沙箱

> 让 LLM 写数据绘图代码并执行（Code Interpreter 模式）等于引入 RCE 风险：模型在同一 token 流里处理数据与指令，注入无法在模型层根治。本文拆解一个两层沙箱——执行前 AST 白名单静态拦截，运行时从零环境加隔离子进程兜底——附七类恶意样本的实测拦截结果，以及白名单在 PyInstaller 打包时带来的两个意外收益。

- Canonical (HTML): https://kaguc.com/blog/code-sandbox-zh/
- Date: 2026-07-29


*English version: [Executing model-generated code: a two-layer sandbox](/blog/code-sandbox/)。“LLM 应用工程”系列第 5 篇。*

## 问题：为什么必须执行模型写的代码

自动报告场景里有一类内容绕不开代码执行：实验数据只有原始文件——.mat 扫场数据、csv 能带——而原有的图多是 MATLAB .fig，无法直接嵌入 PDF。LLM 读得懂数据格式，却画不了图：数据到图需要“计算 + 渲染”，这只能靠代码。于是设计与 Code Interpreter 相同：LLM 写一段 matplotlib 代码，后端在子进程里执行，产出 PNG，编译时嵌入报告。

在 agent 侧这是一个工具 `make_figure`，参数只有两个：`{name, code}`。agent 在[工具循环](/blog/agent-loop-zh/)里读懂数据后调用它；失败会收到报错，可改代码重试；成功后在报告里以 `\includegraphics{用图/<name>}` 引用。系统提示同时立规矩：有数据没图就画，不许伪造图片。

代价也很直接：这等于在后端执行一段由概率部件生成的任意 Python——一个内置的 RCE 入口。本文拆解为它设计的两层沙箱，以及实测结果。

## 威胁模型：模型分不清数据与指令

攻击链是清晰的：不可信的数据文件夹 → 文件内容进入提示词 → 提示注入 → LLM 写出恶意代码 → 后端执行。根因在于 LLM 在同一条 token 流里处理数据与指令（in-band signaling），与 SQL 注入（数据混入查询）、缓冲区溢出（数据混入控制流）同构。这类问题在模型层无法根治：对齐训练能降低概率，给不出保证。业界的共识路径因此是转向“假设注入会成功，控制损害”。

工程推论：安全设计不能依赖“模型不会写坏代码”这个假设，必须在代码离开模型之后、产生任何效果之前设关卡。我们设了两层：**执行前静态拦截**与**运行时隔离**——第一层负责把已知危险模式挡在执行之前，第二层假设第一层会漏，把漏网代码能造成的损害压到最低。

## 第一层：AST 白名单——执行前静态拦截

`ast_check` 把模型代码 parse 成 AST（解析失败即以语法错误拒绝），遍历全部节点，任一违规即拒：

1. **import 白名单**（17 个模块）：只允许 numpy/scipy/matplotlib/pandas/math/csv/json 等数据绘图模块，`import` 与 `from ... import` 都按顶层包名比对。立场是白名单而非黑名单——判断依据是“绘图任务需要什么”，不是“攻击者会用什么”。
2. **危险名黑名单**（37 个名字，查裸名而不只查导入）：os/sys/subprocess/socket/ctypes/importlib/pickle/pathlib/glob……出现即拒。这一条不冗余：执行包装层为实现文件导航预导入了 `os`，模型代码不写 `import os` 也能直接调 `os.remove`——所以必须拦“使用”，不能只拦“导入”。
3. **危险内置**（17 个）：eval/exec/compile/`__import__`/open/getattr/globals……既禁调用也禁裸名引用，`e = eval` 这类别名同样被拒。
4. **dunder 逃逸**：任何 `__xx__` 属性访问一律拒绝，堵住 `().__class__.__subclasses__()` 这条经典逃逸链：

```python
elif isinstance(node, ast.Attribute):
    if node.attr.startswith("__") and node.attr.endswith("__"):
        return False, f"禁止访问 {node.attr}"
```

拒绝不是终点。返回给 agent 的报错是教学式的：写明只能用哪些库、用 `srcpath('文件名')` 取数据、用 `plt.savefig(OUT)` 保存、禁了什么。agent 拿到这条确定性信号后改写重试——与[编译自修复](/blog/compile-self-repair-zh/)是同一个闭环模式：把确定性检查的输出回注给模型。

## 第二层：运行时隔离——漏网之后无损可造

第一层是文本层面的静态分析，理论上可能漏。第二层假设已经漏了，把执行环境削到最小：

| 措施 | 针对的损害 |
|---|---|
| 环境从零构建：只带 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。

```mermaid
flowchart TD
    A["agent 调用 make_figure {name, code}"] --> B{"第一层：ast_check 静态白名单"}
    B -->|拒绝| E["教学式报错：写明可用与禁用项"]
    E --> F["agent 改写代码重试"]
    F --> B
    B -->|通过| W["包装：预导入 np/plt/loadmat，注入 SRC/OUT/srcpath/listdir/walkfiles + _GUARD"]
    W --> R["第二层：隔离子进程执行<br/>从零环境 · 隔离 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` 的两个用例（有变量则执行脚本、无变量则空转）钉死。

## 适用边界

1. **这不是完整沙箱。**无 microVM、无容器级隔离，子进程与宿主同内核、同文件系统权限——AST 被绕过且 `_GUARD` 被规避的组合攻击在理论上存在。项目对此的定位写在模块 docstring 里：面向本地单用户场景（执行的是用户本机自己数据上的绘图代码，等同于用户手动跑脚本），两层已足够；若输入是“不可信文件夹/多用户”，根治要上“一次性无网络容器执行”。
2. **白名单牺牲表达力。**getattr/open 被禁；h5py 等不在白名单，遇到 HDF5 类数据需要显式扩充白名单，并重新评估新库的能力面（能否发网络请求、写文件）。白名单每宽一格，第一层的保证就弱一分。
3. **域限定是前提。**此方案可行的根本原因是任务域窄：“数据绘图”所需的模块集小且稳定。若要执行的是通用代码——agent 自由写任意工具脚本——白名单会宽到失去意义，应直接上容器/microVM 级隔离，AST 层最多做启发式预检。
4. **第二层与平台细节耦合。**从零构建环境意味着平台的隐式依赖要自己踩全：Windows 的系统变量、matplotlib 的可写配置目录。跨平台分发的项目要为此准备实测验证，而不是推理验证。

