数据截至 (上游 commit e3a5b8994b30)
本地受限 Python 执行器(招牌深潜)
这是全库工程含量最高的一支,也是你要带走的精华。模型写的代码不能直接
exec(那等于把 shell 交给 LLM)。smolagents 的做法:自己写一个迷你 Python 解释器,逐个 AST 节点手动解释,只放行明确允许的东西。本章讲这套「笼子」怎么搭。
1. 它要解决的小问题
LLM 写的 Python 你既想跑,又不敢真跑——一句 import os; os.system("rm -rf ...") 就完了。你需要一个「只会执行安全子集」的 Python。
两条路可选:
- 真沙箱(容器/微 VM)隔离整个进程 —— 强,但重、要外部依赖。
- 不用真
exec,自己解释代码 —— 轻,纯 Python 内进程,但要自己堵住每个洞。
smolagents 本地默认走第 2 条(LocalPythonExecutor),想要真隔离再上第 1 条(远程执行器,见 06)。
2. 核心思路:逐节点解释 AST,而不是 exec
普通执行是 exec(code)——把整段交给 CPython。smolagents 改成:
代码字符串
│ ast.parse → 语法树(AST)
▼
for 每个顶层语句 node:
evaluate_ast(node) → 一个大 if/elif,按节点类型手动解释
evaluate_ast(local_python_executor.py:1417)是核心分发器:它认得 ast.Assign、ast.Call、ast.For、ast.Import……每种节点交给对应的 evaluate_* 函数。没被显式支持的节点类型 = 不能执行。 这是「默认拒绝」:笼子的第一层是——语言特性本身就是白名单。
直觉:普通解释器「能跑就跑」;这个解释器「我认识、且允许,才跑」。
3. 五道闸门(笼子怎么关住代码)
闸门一:导入白名单
只有在授权列表里的模块能 import(evaluate_import,local_python_executor.py:1309)。基础白名单很克制(utils.py:49,BASE_BUILTIN_MODULES):collections / datetime / itertools / math / queue / random / re / stat / statistics / time / unicodedata——都是纯计算、无副作用的。
用户可用 additional_authorized_imports 追加(如 numpy、pandas)。放行判定看 check_import_authorized,支持 pandas.* 这类前缀授权;传 "*" 则放开所有(库会警告「自负风险」,agents.py:1578-1582)。
即便放行,导入的模块也会被 get_safe_module(local_python_executor.py:1271)递归重建一份副本再交出去,而不是原对象——顺带对嵌套子模块继续做授权检查。
闸门二:危险模块/函数黑名单 + 返回值二次检查
光挡 import 不够——代码可能拐 着弯拿到危险对象(比如通过某个已授权对象的属性摸到 os)。于是有第二层:检查每次求值的返回值。
check_safer_result(local_python_executor.py:156)在 safer_eval 装饰器(:185)里对关键求值的返回值兜底检查:
- 返回的是模块?→ 必须在授权列表里,否则
Forbidden access to module。 - 返回的是函数?→ 不能是黑名单里的危险函数。
黑名单很明确(local_python_executor.py:130-153):
| 类别 | 拦掉的东西 | 常量 |
|---|---|---|
| 危险模块 | os sys subprocess socket pathlib shutil io builtins multiprocessing pty | DANGEROUS_MODULES |
| 危险函数 | eval exec compile __import__ globals locals os.system os.popen posix.system | DANGEROUS_FUNCTIONS |
关键点:它不只在「调用点」拦,还在「值流动到哪」都拦——你哪怕只是让一个变量指向 os.system(还没调),二次检查也会在那次求值时发现并报错。
闸门三:内建函数白名单
代码能用的内建不是 Python 全套,而是一张手挑的安全表 BASE_PYTHON_TOOLS(local_python_executor.py:74):print len range sum sorted 加一堆 math.*……没有 open、没有 eval、没有 __import__。 getattr 还被换成 nodunder_getattr(:68、:121),挡掉通过 getattr 摸 dunder 属性的路子。
evaluate_call(local_python_executor.py:825)里对函数调用还有两道额外检查:
- 调到一个「CPython 内建、又没被显式登记为工具」的函数 → 拒(
:906-909)。 - 调到名字形如
__xxx__的 dunder 方法、又不在允许清单 → 拒(:910-917)。堵的是().__class__.__bases__...这类经典逃逸链。
闸门四:计步器 + while 上限(挡资源耗尽)
evaluate_ast 每被调一次就给 _operations_count 加一,超过 MAX_OPERATIONS = 10_000_000 直接报错(local_python_executor.py:58、1444-1448)。while 循环单独限 MAX_WHILE_ITERATIONS = 1_000_000(:59、:457-458)。挡的是模型不小心写出死循环 / 天量计算。
闸门五:墙钟超时
整段执行套一个 timeout 装饰器(local_python_executor.py:285),默认 MAX_EXECUTION_TIME_SECONDS = 30(:60)。它用 ThreadPoolExecutor 实现(跨平台、可在任意线程用,不依赖 signal)。诚实的注释也点明局限:超时后那个后台线程杀不掉,会继续跑到自己结束,只是调用方先拿到 TimeoutError(:298-301)。
4. 原理演示(把「逐节点解释」演出来)
重点看:调用节点被拦在「查白名单」这一步——名字不在允许集合里就直接拒,根本没机会执行。
# 示意,非源码:极简版 evaluate_call 的精神
def evaluate_call(node, state, static_tools):
name = node.func.id
if name in state: func = state[name] # 用户在代码里定义的
elif name in static_tools: func = static_tools[name] # 注入的工具/安全内建
else:
raise InterpreterError(f"禁止调用:'{name}' 不在允许的工具里")
args = [evaluate_ast(a, state, static_tools) for a in node.args]
return func(*args) # 只有过了白名单才真正调用
真实版还要处理 *args/**kwargs 解包、super()、把 print 重定向到状态里的缓冲区等(local_python_executor.py:867-918)。
5. 状态、工具注入与最终答案
执行是有状态的:LocalPythonExecutor.state(local_python_executor.py:1716)在多次调用间保留变量,所以模型第 2 步能用第 1 步定义的变量。print 的输出攒在 state["_print_outputs"](一个 PrintContainer,:240),执行完作为日志取出(__call__,:1747-1758)。
工具怎么进来:send_tools(:1763)把 {工具} + BASE_PYTHON_TOOLS + 额外函数 合成 static_tools;send_variables(:1760)灌初始变量。二者由 agent 在 run 开头调用(agents.py:490-492)。
final_answer 的异常终止机制见 02-code-agent.md §5——正是在 evaluate_python_code(:1583)里包装并捕获。
static_tools vs custom_tools:前者是注入的工具/内建,代码里不能覆盖(赋值会报错);后者可被代码覆盖(:1601-1606)。
6. 边界与局限(诚实交代)
库自己在类 docstring 里写得很直白(local_python_executor.py:1692-1693):
"It is not a security sandbox: for isolated execution of untrusted code, use a remote executor."
把这句话摊开:
- 它是「减害」不是「隔离」。 五道闸门大幅缩小攻击面,但同进程运行意味着——一旦有某条没堵住的 AST 路径,就能碰到宿主。作者用「非穷举列表」形容黑名单(
:129),等于承认黑名单不可能完备。 - 真要跑不可信代码 → 上远程沙箱(E2B/Docker/Modal/Blaxel,见
06)。本地执行器定位是「可信度较高、要低延迟无依赖」的场景。 - 不是完整 Python:不支持的语法节点直接不能用;某些动态特性(元类花招、任意反射)被刻意阉割。
7. 巧妙之处(可借鉴)
- 「默认拒绝」贯穿三层:语法节点、可导入模块、可调函数/内建,全是白名单而非黑名单——黑名单只作二次兜底。
- 在「值流动」处检查,而非只在「调用」处:
check_safer_result让「拿到危险对象的引用」本身就非法,堵住迂回获取。 FinalAnswerException继承BaseException:一个类型选择就防住了「模型代码的宽泛 except 吃掉终止信号」(见02§5)。- 报错写给模型看:下标越界会用
difflib提示相近的 key(:934-937);这类「教学式报错」帮模型下一轮自我纠正。
8. 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| AST 分发核心 | src/smolagents/local_python_executor.py | evaluate_ast |
| 调用求值(含内建/dunder 守卫) | src/smolagents/local_python_executor.py | evaluate_call |
| 导入放行 + 模块重建 | src/smolagents/local_python_executor.py | evaluate_import / get_safe_module |
| 返回值二次检查 | src/smolagents/local_python_executor.py | check_safer_result / safer_eval |
| 危险清单 | src/smolagents/local_python_executor.py | DANGEROUS_MODULES / DANGEROUS_FUNCTIONS |
| 安全内建白名单 | src/smolagents/local_python_executor.py | BASE_PYTHON_TOOLS |
| 计步/循环上限 | src/smolagents/local_python_executor.py | MAX_OPERATIONS / MAX_WHILE_ITERATIONS |
| 超时 | src/smolagents/local_python_executor.py | timeout |
| 顶层执行 + final_answer 捕获 | src/smolagents/local_python_executor.py | evaluate_python_code / FinalAnswerException |
| 执行器封装 | src/smolagents/local_python_executor.py | LocalPythonExecutor |
| 基础可导入模块 | src/smolagents/utils.py | BASE_BUILTIN_MODULES |