《AI Agent 工程实战》10.3 工具沙箱:子进程/容器隔离高危工具

Agent 会执行模型写出来的代码、SQL 与命令,必须隔离。本节讲三层隔离:进程内不可信、子进程加资源限制、容器加只读挂载与无网络。实现 sandboxed_python 真跑正常代码、死循环超时、写文件三例,如实说明 subprocess 不是安全边界,再用 Docker 只读无网络容器真跑对照。

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 写入流水线:抽取、去重、冲突消解 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距