本地受限 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)。