跳到主要内容

本地受限 Python 执行器(招牌深潜)

这是全库工程含量最高的一支,也是你要带走的精华。模型写的代码不能直接 exec(那等于把 shell 交给 LLM)。smolagents 的做法:自己写一个迷你 Python 解释器,逐个 AST 节点手动解释,只放行明确允许的东西。本章讲这套「笼子」怎么搭。

1. 它要解决的小问题

LLM 写的 Python 你既想跑,又不敢真跑——一句 import os; os.system("rm -rf ...") 就完了。你需要一个「只会执行安全子集」的 Python。

两条路可选:

  1. 真沙箱(容器/微 VM)隔离整个进程 —— 强,但重、要外部依赖。
  2. 不用真 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.Assignast.Callast.Forast.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 追加(如 numpypandas)。放行判定看 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 ptyDANGEROUS_MODULES
危险函数eval exec compile __import__ globals locals os.system os.popen posix.systemDANGEROUS_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:581444-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.pyevaluate_ast
调用求值(含内建/dunder 守卫)src/smolagents/local_python_executor.pyevaluate_call
导入放行 + 模块重建src/smolagents/local_python_executor.pyevaluate_import / get_safe_module
返回值二次检查src/smolagents/local_python_executor.pycheck_safer_result / safer_eval
危险清单src/smolagents/local_python_executor.pyDANGEROUS_MODULES / DANGEROUS_FUNCTIONS
安全内建白名单src/smolagents/local_python_executor.pyBASE_PYTHON_TOOLS
计步/循环上限src/smolagents/local_python_executor.pyMAX_OPERATIONS / MAX_WHILE_ITERATIONS
超时src/smolagents/local_python_executor.pytimeout
顶层执行 + final_answer 捕获src/smolagents/local_python_executor.pyevaluate_python_code / FinalAnswerException
执行器封装src/smolagents/local_python_executor.pyLocalPythonExecutor
基础可导入模块src/smolagents/utils.pyBASE_BUILTIN_MODULES