10.3 工具沙箱:子进程/容器隔离高危工具
10.2 说「别做万能 exec」,但现实里总有些工具本质上就是执行模型写出来的东西:跑一段数据清洗脚本、执行一条分析 SQL、调用一个命令行工具。这类工具的入参不是「字段」,而是代码——而代码可以死循环、可以读你的密钥文件、可以删你的数据库。把它们和 Agent 进程放在一起,等于把整个服务交给了模型。
本节把 ResearchCopilot 推进到「高危工具有围栏」:实现一个
sandboxed_python(code)工具,用子进程 + 超时 + 输出上限隔离;再上 Docker 只读、无网络容器做真正的边界。全部代码在 Python 3.14.6、Docker 29.7.2 实测跑通。
10.3.1 为什么必须隔离
工具是我们自己写的,但工具的入参是模型写的。这两句话的区别是全部问题的根:函数体可信,参数不可信。一段 exec 型工具收到的是任意代码,它可能:
- 死循环:
while True: pass把 CPU 占满,Agent 循环永远等不到返回。 - 读写文件:读
/etc/passwd、写~/.ssh/authorized_keys,越权访问宿主。 - 发起网络:把内网数据外传,或对内网服务做扫描。
- 耗尽资源:
[0] * 10**9吃光内存,把整个服务 OOM 掉。
卷一的线程池超时(4.2)只能挡住「卡住」这一类,而且超时后线程并不会被杀——它还在后台跑。要挡住其余三类,必须下沉到进程边界,因为只有进程能被强制杀死、被限制资源、被切断网络。
10.3.2 三层隔离:进程内 → 子进程 → 容器
隔离手段是分层的,每一层挡住的东西不同:
| 层级 | 手段 | 挡得住 | 挡不住 |
|---|---|---|---|
| 进程内 | try/except | 普通异常 | 段错误、os._exit |
| 线程 | ThreadPoolExecutor + 超时 | 逻辑卡死 | CPU 密集、内存爆炸、文件/网络 |
| 子进程 | subprocess + 超时 + -I | 崩溃、死循环、环境隔离 | 文件读写、网络(无内核隔离) |
| 容器 | docker run 只读 + 无网络 + 限资源 | 文件、网络、资源、进程 | 内核漏洞、配置疏漏 |
关键认知:子进程是「隔离崩溃」,不是「隔离权限」。它让一段坏代码杀不掉主进程,但坏代码依然以你的用户身份运行,能读你能读的所有文件。真正的权限隔离要落到容器——本节两层都讲,并把各自的边界说清楚。
10.3.3 sandboxed_python:子进程 + 超时 + 输出上限
先写子进程版。它用 subprocess.run 起一个独立 Python 进程,加三个约束:超时(挡住死循环)、输出上限(挡住爆 token)、-I 隔离模式(忽略环境变量与用户 site,避免 PYTHONPATH 注入):
# minagent/sandbox.py
from __future__ import annotations
import json, subprocess, sys
def sandboxed_python(code: str, timeout: float = 2.0, max_output: int = 2000) -> str:
"""在子进程里跑一段代码:加超时、限输出长度,出错返回 JSON 而非抛异常。"""
try:
proc = subprocess.run(
[sys.executable, "-I", "-c", code], # -I:忽略环境变量与用户 site
capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
return json.dumps({"error": f"timeout after {timeout}s"}, ensure_ascii=False)
out = (proc.stdout or "")[:max_output]
err = (proc.stderr or "")[:max_output]
if proc.returncode != 0:
return json.dumps({"error": err.strip() or f"exit code {proc.returncode}"},
ensure_ascii=False)
return out.strip()
三处细节值得停一下:
timeout=在subprocess.run上:超时会真的杀掉子进程(不像线程超时只是提前返回),这是子进程相对线程的核心优势。[:max_output]截断:模型看不到被截断的部分,但至少不会因为一次print出 5000 行就把上下文挤爆。截断是降级,不是丢弃——返回值仍然合法。- 出错返回
{"error": ...}:延续卷一契约,子进程崩溃对 Agent 来说只是「工具返回了一条错误」,循环照常继续。
10.3.4 真跑:正常 / 死循环 / 写文件
三种典型输入,一次跑完:
print("① 正常:", sandboxed_python("print(6 * 7)"))
print("② 死循环:", sandboxed_python("while True:\n pass", timeout=1.0))
print("③ 写文件:", sandboxed_python(
"open('/tmp/agentbook/chk/escape.txt', 'w').write('pwned')\nprint('wrote escape.txt')"))
实测输出(Python 3.14.6):
① 正常: 42
② 死循环: {"error": "timeout after 1.0s"}
③ 写文件: wrote escape.txt
前两行符合预期:正常代码拿到 42,死循环被 1 秒超时杀掉、返回结构化错误。第三行是本节的重点,也是要坦白的地方——写文件成功了。子进程没有任何文件系统限制,它以宿主用户的身份,能往任何有权限的路径写东西。这就是下一节要讲的「局限」。
10.3.5 子进程不是安全边界:如实说明局限
把 sandboxed_python 当成「安全沙箱」是危险的误解。再跑三个反例,看清它到底挡不住什么:
print("读宿主文件:", sandboxed_python("print(open('/etc/hosts').read().splitlines()[0])"))
print("网络:", sandboxed_python(
"import socket; socket.create_connection(('1.1.1.1', 80), timeout=2); print('connected')"))
print("输出截断:", sandboxed_python("print('A' * 5000)", max_output=20))
实测输出:
读宿主文件: ##
网络: connected
输出截断: AAAAAAAAAAAAAAAAAAAA
三行各暴露一个缺口:
| 反例 | 结果 | 说明 |
|---|---|---|
读 /etc/hosts | 成功 | 子进程能读宿主能读的一切 |
连 1.1.1.1:80 | 成功 | 子进程能发起任意网络请求 |
| 超长输出 | 被截到 20 字符 | 这一项确实挡住了 |
所以 sandboxed_python 的准确定位是:「隔离崩溃与资源耗尽」的护栏,不是「隔离权限」的沙箱。它适合跑你相对信任、只是可能写错的代码(比如自家生成的统计脚本);绝不适合跑用户上传或完全不可信的代码。这一点必须在设计文档里写清楚,否则「我们加了沙箱」会给人虚假的安全感。真要跑不可信代码,往下走一层。
10.3.6 接进注册表:沙箱工具也是工具
沙箱不是独立的东西,它就是一个工具的实现细节——对外仍是卷一的 Tool,走同一套 ToolRegistry。把它注册进去,Agent 就能像调普通工具一样调它:
reg = ToolRegistry()
reg.add(Tool(
name="run_python",
description="在沙箱子进程里执行一段 Python 代码并返回其 stdout。"
"只做数值计算与字符串处理,无文件、无网络。",
parameters={"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"]},
func=lambda code: sandboxed_python(code, timeout=2.0),
))
用假模型驱动一次「正常 + 死循环」两连调,看沙箱在 Agent 循环里的表现:
[0] tool_call: run_python -> 1024
[1] tool_call: run_python -> {"error": "timeout after 2.0s"}
[2] final: 2 的 10 次方是 1024;第二段代码超时被沙箱杀掉了。
第 1 步的死循环没有拖垮循环——沙箱杀掉子进程、返回错误 JSON,Agent 继续走到第 2 步收敛。这正是「沙箱 + 卷一错误契约」配合的效果:坏代码既害不了主进程,也害不了会话。
还有两件接入时必须做的事。其一,给沙箱工具定危险档:run_python 按 4.3 应归 dangerous,因为它执行的是模型写的代码,进注册表时要挂审批,不能和只读工具一视同仁。其二,描述里写清边界:run_python 的描述点明「无文件、无网络」,既告诉模型它能干什么,也提醒调用方这是受限环境——描述即提示词(10.2.5)在沙箱工具上同样成立。
10.3.7 容器沙箱:只读 + 无网络
容器提供内核级的隔离,能补上子进程缺的那三项。核心是三个开关:
--network=none:容器没有网络接口,外传数据、扫内网都做不到。--read-only:根文件系统只读,写文件直接报Read-only file system。--memory/--cpus/--pids-limit:限制内存、CPU、进程数,挡住资源耗尽与 fork 炸弹。
两种方案怎么选,取决于「代码有多不可信」和「调用有多频繁」:
| 维度 | 子进程 subprocess | 容器 docker run |
|---|---|---|
| 启动开销 | 毫秒级 | 数百毫秒到一秒 |
| 文件系统隔离 | 无 | --read-only |
| 网络隔离 | 无 | --network=none |
| 资源限制 | 仅超时 | 内存 / CPU / 进程数 |
| 依赖管理 | 复用宿主环境 | 须打进镜像 |
| 适用 | 自家生成的、可能写错的代码 | 用户上传、完全不可信的代码 |
经验法则:能用子进程解决的,别上容器——容器更安全,但启动成本和依赖打包的复杂度都高一个量级。只有当代码来源不可信、且必须防越权时,才值得付这份代价。
# minagent/sandbox_docker.py
from __future__ import annotations
import json, subprocess
IMAGE = "python:3.14-slim"
def docker_sandboxed_python(code: str, timeout: float = 5.0, max_output: int = 2000) -> str:
"""用一次性容器跑代码:无网络、只读根、限资源、跑完即删。"""
try:
proc = subprocess.run(
["docker", "run", "--rm", "--network=none", "--read-only",
"--memory=256m", "--cpus=0.5", "--pids-limit=64",
IMAGE, "python", "-I", "-c", code],
capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
return json.dumps({"error": f"timeout after {timeout}s"}, ensure_ascii=False)
out = (proc.stdout or "")[:max_output].strip()
err = (proc.stderr or "")[:max_output].strip()
if proc.returncode != 0:
return json.dumps({"error": (err.splitlines()[-1] if err else f"exit {proc.returncode}")},
ensure_ascii=False)
return out
--rm 保证容器用完即删,不留残留;err.splitlines()[-1] 只取错误信息最后一行(Python 的 traceback 末行才是真正的异常),避免把整段栈回灌给模型、白烧 token。
10.3.8 真跑:Docker 只读无网络容器
本机 Docker 可用(29.7.2),镜像 python:3.14-slim 已在本地。三个场景真跑:
print("① 正常:", docker_sandboxed_python("print(sum(range(101)))"))
print("② 写文件:", docker_sandboxed_python("open('/tmp/x', 'w').write('pwned')"))
print("③ 网络:", docker_sandboxed_python(
"import socket; socket.create_connection(('1.1.1.1', 80), timeout=2); print('connected')"))
实测输出:
① 正常: 5050
② 写文件: {"error": "OSError: [Errno 30] Read-only file system: '/tmp/x'"}
③ 网络: {"error": "OSError: [Errno 101] Network is unreachable"}
对照 10.3.4 的子进程版:同一个「写文件」动作,子进程下成功、容器下被拒;子进程下畅通的网络,容器下直接 Network is unreachable。这两条正是容器补上的缺口。等价的手工命令,读者可以在自己机器上复现(需先 docker pull python:3.14-slim):
docker run --rm --network=none --read-only python:3.14-slim python -c "print(6*7)"
# 输出 42
docker run --rm --network=none --read-only python:3.14-slim \
python -c "open('/tmp/x','w').write('pwned')"
# OSError: [Errno 30] Read-only file system: '/tmp/x'
真实环境 / 真实模型下会怎样:真实模型写的代码比本节演示的复杂得多,可能 import 第三方库、可能跑几十秒。容器方案下要预装依赖(把库打进镜像,而不是运行时 pip install),否则每次调用都要联网下载——而容器恰恰没网络。另一个真实差异是启动开销:docker run 冷启动几百毫秒到一秒,比子进程贵一个数量级,所以高频的轻量工具仍走子进程,只有真正高危的才进容器。还有一类本机未执行的场景要如实说明:本节没演示 gVisor / Firecracker 这类「真·微虚拟机沙箱」,它们能挡住容器逃逸,但需要额外安装运行时,本机未部署,故不贴输出。验收清单:
- 执行模型代码的工具走子进程或容器,绝不在主进程
exec - 子进程版带超时、输出上限、
-I,且文档写明「不是安全边界」 - 不可信代码走容器:
--network=none --read-only+ 内存/CPU/进程限制 - 容器依赖已打进镜像,不依赖运行时联网
- 超时杀掉的是真进程/真容器,不留孤儿
阅读导航:上一节:10.2 工具设计模式:读写分离、幂等、可观测 · 下一节:11.1 写入流水线:抽取、去重、冲突消解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。