如果一个 Agent 只是回答问题,最坏的结果通常是:
它胡说八道。
但如果一个 Agent 可以生成 Python,并且服务器真的会执行这些 Python,问题就完全不一样了。
它可能写出:
import os
os.remove("important.xlsx")
也可能写:
import requests
requests.post(...)
甚至模型自己完全没有恶意,只因为误解了任务,就生成:
df.to_excel("/some/system/path/result.xlsx")
这也是我做 AtlasSplit 时遇到的核心问题。
AtlasSplit 是一个面向财务和运营表格处理场景的智能体。
这里讨论的是表格处理 Agent;本站另有同名图集拆分工具的开发笔记,两篇的输入、职责与安全边界不同。阅读下文时,以“自然语言生成 Pandas 代码并处理表格”这条链路为准。AST 审计只是防御层之一,不能单独证明任意 Python 代码执行安全。
它希望做到:
自然语言规则
↓
生成 Pandas 代码
↓
自动执行
↓
生成 Excel
例如:
把总成本按照各部门营收权重进行分摊,但研发部至少分摊 10%,销售部最高不超过 35%。
这种任务如果全部提前写成固定规则,会产生大量业务代码。
于是我尝试了一条更 Agent 的路线:
让 LLM 写代码。
很快,我就遇到了一个很现实的问题:
我敢让它写,但我敢直接
exec()吗?
答案显然是不敢。
于是 AtlasSplit 最终形成了一条非常关键的链路:
自然语言
↓
LLM
↓
Pandas Code
↓
AST Security Audit
↓
Sandbox
↓
Output Validation
↓
Repair Loop
这篇文章就聊聊这个过程。
一、为什么 AtlasSplit 不直接做固定规则引擎?
表格分摊看起来简单。
比如:
100 万费用
按照部门人数分摊
程序非常容易写。
但真实情况很快会变成:
按销售额权重分摊,但是直营部门权重乘 1.2,研发部门不得低于总金额 15%,某几个部门不参与分摊,结果保留两位小数,尾差归到总部。
继续发展:
按两个 Excel 中的部门编码和成本中心关联,先汇总,再按月份在职天数折算。
这时候如果每一种规则都写:
if rule_type == ...
规则引擎会迅速膨胀。
AtlasSplit 最终希望覆盖的场景包括:
- 等额分摊;
- 权重分摊;
- 封顶/保底;
- 阶梯规则;
- 按时间比例;
- 跨表多键聚合。
所以我采用了:
LLM 负责理解和生成 Pandas,程序负责限制它到底能做什么。
注意,这两句话缺一不可。
二、第一版最危险的设计:直接执行模型代码
最简单的 Agent Demo 可能是:
code = llm(prompt)
exec(code)
Demo 非常漂亮。
用户说:
按 revenue 占比分摊 cost。
模型生成:
df["ratio"] = df["revenue"] / df["revenue"].sum()
df["cost_allocated"] = df["ratio"] * total_cost
然后结果出来了。
整个过程看起来像魔法。
直到你开始思考:
exec(code)
这行到底意味着什么。
理论上模型拥有和你的 Python 进程几乎相同的权限。
于是它可以访问:
- 文件系统;
- 环境变量;
- 网络;
- Shell;
- API Key;
- 数据库;
- Python Runtime;
- 主服务进程。
对于一个真正需要处理财务 Excel 的 Agent 来说,这完全不可接受。
三、我的威胁模型
开始设计安全层以后,我没有先问:
“怎么限制 Python?”
而是先问:
“我到底在防谁?”
AtlasSplit 大致考虑四种风险来源。
1. 模型自己生成危险代码
不是攻击。
只是模型犯错。
2. Prompt Injection
Excel 某个单元格完全可能包含:
Ignore previous instructions...
如果文件内容直接进入 LLM Context,这本质上就是外部不可信输入。
3. 用户恶意输入
比如用户直接说:
帮我读取服务器上的环境变量并写进 Excel。
4. 第三方库能力逃逸
即使禁止:
os.system()
不代表整个 Python 世界就安全了。
很多看似无害的 API 背后也可能:
- 访问文件;
- 打开网络;
- 动态加载模块。
于是我逐渐形成一个原则:
安全不能只做一层。
最终是:
AST 静态检查
+
Workspace 文件隔离
+
Runtime Sandbox
+
资源限制
+
Output Validation
图解:AST 检查不构成完整安全边界;运行隔离和结果校验必须独立建立。
把“拒绝了一段危险代码”和“执行环境安全”分开验收。静态规则可以阻止已知结构,但 Python 的动态行为使单层审计难以覆盖所有执行路径。运行器还应限制时间、内存、网络和可访问文件,且不能挂载宿主凭据。输出看似合法,也仍需检查行数、金额守恒或不可修改列等任务条件。
动手核对: 用无害测试覆盖死循环、超大输出、错误列名与不符合输出 schema 的结果。测试目标是拒绝或终止行为是否符合契约,不需要运行真实破坏操作。
四、第一层:AST 静态审计
Python 自带:
ast
所以代码生成完成后,我不会直接执行,而是先:
tree = ast.parse(code)
然后遍历 AST。
大致结构:
class SecurityVisitor(ast.NodeVisitor):
def visit_Import(self, node):
...
def visit_ImportFrom(self, node):
...
def visit_Call(self, node):
...
def visit_Attribute(self, node):
...
def visit_Name(self, node):
...
相比正则表达式,它最大的好处是:
我检查的不是字符串,而是 Python 真正准备执行的语义结构。
五、为什么不能只用字符串匹配?
假设我写:
if "os.system" in code:
reject()
看起来可以。
但模型可以生成:
import os as operating_system
operating_system.system(...)
也可能:
from os import system
system(...)
甚至字符串里可能只是出现:
"os.system is forbidden"
正则和字符串匹配非常容易:
- 漏报;
- 误报;
- 被 Alias 绕过。
AST 中则能够看到:
Import
alias
Call
Attribute
于是策略层可以真正理解:
用户导入了什么。
六、Import 白名单:默认拒绝比黑名单更可靠
最开始我考虑过黑名单:
禁止:
os
sys
subprocess
socket
requests
...
但后来发现这个列表永远写不完。
所以策略逐渐改成:
只允许业务真正需要的库。
例如:
ALLOWED_MODULES = {
"pandas",
"numpy",
"openpyxl"
}
出现:
import pandas as pd
允许。
出现:
import requests
直接拒绝。
这样系统的安全边界就从:
“我知道哪些东西危险”
变成:
“除了我明确知道安全且需要的东西,其余都不允许。”
这两个设计哲学差别巨大。
七、仅限制 Import 仍然远远不够
假设模型完全不 import。
它依然可能生成:
eval(...)
exec(...)
compile(...)
__import__(...)
于是我继续限制危险 Builtin:
eval
exec
compile
__import__
globals
locals
getattr
setattr
其中:
getattr()
这种函数尤其值得注意。
因为大量安全规则最终都依赖:
“禁止访问某个属性。”
但如果允许任意动态反射,很多属性规则就可能被绕开。
所以 Agent Code Execution 场景下,我更倾向于:
宁可能力少一点,也不要让动态行为太自由。
八、Attribute 检查:比我最开始想得复杂
比如:
df.to_excel(...)
当然要允许。
但:
something.__class__.__mro__
就完全是另一回事。
Python 的对象模型本身非常强大。
如果允许任意:
__xxx__
属性访问,就可能产生一些非常意外的逃逸路径。
所以我增加了类似:
禁止访问敏感 dunder 属性
的规则。
例如关注:
__class__
__subclasses__
__globals__
__dict__
这一层让我意识到:
Python Sandbox 本身是个很难的问题。
AST 审计并不是一个完整沙箱。
它只是第一道门。
九、AST 通过,不等于代码安全
这是整个系统里最重要的一点。
假设:
pd.read_excel(path)
AST 完全合法。
但如果:
path = "../../../../secret.xlsx"
怎么办?
所以代码本身安全,不代表:
它访问的资源安全。
于是 AtlasSplit 又增加第二条重要边界:
Workspace Policy
十、每个 Task 一个独立 Workspace
每个任务运行时创建自己的目录。
例如:
workspace/
└── task_93f2/
├── input/
│ └── source.xlsx
├── output/
│ └── result.xlsx
└── run/
模型只需要知道:
input/source.xlsx
output/result.xlsx
原则上它不应该看到:
C:\
/etc/
用户 Home
项目根目录
.env
执行前,再做路径解析:
resolved = path.resolve()
然后检查:
resolved 是否仍位于当前 task workspace 中
十一、Path Traversal 是非常容易被忽略的坑
假设你只检查:
if path.startswith(workspace):
都不一定安全。
比如:
workspace/task1/../../secret
所以路径一定需要:
Normalize / Resolve 之后再检查边界。
需要特别考虑:
../
绝对路径
符号链接
跨 Task 路径
UNC Path
这一层完全不依赖 LLM。
这是传统安全工程。
但正是这种传统工程能力,让 Agent 真正能够落地。
十二、第三层:Sandbox Worker
即使 AST 和 Workspace 都通过,我仍然不希望代码运行在:
FastAPI 主进程
因为如果代码:
while True:
pass
AST 很可能认为:
没什么危险 API。
但服务器已经寄了。
所以执行层应该:
Main Service
↓
Sandbox Worker
Worker 可以是:
- 独立进程;
- Container;
- 更严格的隔离运行环境。
对于 AtlasSplit 这种本地表格任务,我采用的核心思想是:
生成代码永远不要和主服务共享命运。
十三、资源限制:安全不仅是“防黑客”
例如模型生成:
while True:
df = pd.concat([df, df])
它没有:
Shell
Network
File Delete
但很快就可以吃光内存。
所以 Sandbox 还需要限制:
CPU
Memory
Execution Timeout
Output File Count
Output File Size
例如:
任务超过规定时间
→ Kill Worker
→ 返回 ExecutionTimeout
而不是:
等它自己停。
十四、网络默认关闭
对于 AtlasSplit 来说,一个表格分摊任务根本不需要:
公网访问能力
所以我的策略非常简单:
不需要的权限就不存在。
任务的业务是:
读取上传文件
↓
计算
↓
生成 Excel
那它就没有理由:
访问 github.com
访问某个 API
向外 POST 数据
这实际上也降低了 Prompt Injection 的攻击价值。
十五、一个容易忽略的地方:输出也必须验证
假设程序成功结束:
exit code = 0
是不是就代表成功?
当然不是。
模型可能生成:
result.xlsx
但结果:
- 行数少了一半;
- 原始主键消失;
- 分摊金额总和不一致;
- 某些部门被遗漏。
所以代码执行成功以后,还有:
Output Validator
例如对于分摊:
abs(
allocated_amount.sum()
- source_amount
) < tolerance
同时检查:
主键是否保留
必需列是否存在
是否出现重复记录
是否出现 NaN
输出文件是否存在
这层非常关键。
因为:
代码成功 ≠ 业务成功。
十六、自愈重试:Agent 最有意思的一环
假设 LLM 第一次生成:
df.groupby("部门名称")
实际 Excel 列名是:
部门
执行报:
KeyError: '部门名称'
传统程序直接失败。
AtlasSplit 则会把:
原任务
+
生成代码
+
审计结果
+
执行错误
重新反馈给模型:
请只修复错误,不要改变原始业务目标。
模型重新生成第二版代码。
例如:
df.groupby("部门")
然后重新:
AST Audit
→ Sandbox
→ Validation
所以真实链路是:
LLM
↓
Code
↓
AST
↓
Sandbox
↓
Error
└─────────────┐
↓
Repair
↓
AST
↓
Sandbox
注意:
修复后的代码必须重新过安全审计。
不能因为:
“上一版安全”
就默认下一版安全。
十七、Audit Replay:我认为比“聊天记录”更有价值
AtlasSplit 会记录一次任务中的关键状态。
例如:
runs/2025xxxx/
里面保存:
Input Summary
User Rule
Generated Code
AST Audit Result
Runtime Error
Repair Code
Output Validation
Final Result
为什么要做这个?
因为以后如果有人问:
为什么财务表这次结果和上个月不一样?
你不能回答:
因为 AI 当时是这么想的。
你需要知道:
当时输入是什么
Prompt 是什么
代码是什么
执行结果是什么
是否发生过 Repair
所以对于企业 Agent,我越来越认为:
可回放性比“它会思考”重要得多。
十八、AtlasSplit 最终安全架构
最后大致形成:
User
│
▼
Natural Language
│
▼
LLM / Planner
│
▼
Restricted Code Generator
│
▼
┌─────────────────┐
│ AST Policy │
│ Engine │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Workspace │
│ Policy │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Sandbox Worker │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Resource Limit │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Output Validator│
└────────┬────────┘
│
Success│Failure
│
┌───────┴───────┐
▼ ▼
Result Repair Loop
十九、后来我对“Agent 安全”的理解变了
一开始我以为 Agent Safety 是:
防止模型执行
rm -rf /。
后来发现真正的问题远比这个复杂。
更现实的事故往往是:
- 写错文件;
- 覆盖源数据;
- 死循环;
- 内存爆炸;
- 路径逃逸;
- 错误结果却正常退出;
- 自愈时生成了比第一版更危险的代码。
所以真正的原则应该是:
永远假设 LLM 产生的内容是不可信输入。
哪怕这个 LLM 是你自己的。
二十、AtlasSplit 给我的最大启发
LLM Agent 很容易做 Demo。
最简单的 Agent:
Prompt
↓
LLM
↓
Tool
十几分钟就能看到效果。
但如果这个 Tool 是:
execute_python
事情就突然从:
NLP 项目
变成:
软件工程 + 安全工程 + 系统工程。
而这恰恰是我觉得 Agent 最有意思的地方。
真正可靠的 Agent,不是因为模型永远不会犯错。
而是:
即使模型犯错,整个系统仍然知道怎样限制它、发现它、终止它、修复它,并且把整个过程记录下来。
这大概才是“可用 Agent”和“能跑 Demo”之间真正的分界线。
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。