tools/environments/,12 个文件。这一章讲工具实际在哪里跑 —— 也就是纵深防御里唯一真正的硬边界。
| 环境 | 大小 | 隔离程度与用途 |
|---|---|---|
local.py本机 |
91.9 KB | 默认。⚠️ 无沙箱。命令直接交给宿主机的 shell 执行。速度最快,但智能体拥有和你完全相同的权限 |
docker.py容器 |
91.2 KB | 在 Docker 容器里执行。文件系统、进程、网络都被隔离。这是最常见的生产选择 |
modal.pymanaged_modal.py |
16.9 + 9.7 KB | Modal 云端沙箱。按需启动、闲置零成本。适合「智能体大部分时间在睡觉」的场景 |
vercel_sandbox.py |
21.0 KB | Vercel 的沙箱服务 |
daytona.py |
9.8 KB | Daytona 远程开发环境 |
singularity.py |
10.0 KB | Singularity 容器 —— 高性能计算集群常用(大学、科研机构的 GPU 集群通常不给 Docker 权限,只给 Singularity) |
ssh.py |
17.1 KB | 通过 SSH 在另一台主机上执行 |
另外两个辅助模块:base.py(68.3 KB,抽象基类与共用逻辑)和 file_sync.py(20.2 KB,宿主机与环境之间的文件同步)。
审计检查了约 36.4 万行代码。结论是:没有发现恶意代码、后门或隐藏的数据上报。
但同时报告了 4 个「严重」(critical)级别、9 个「高」(high)级别的架构问题。头号问题是:
在默认的本机后端下,terminal 工具把命令直接交给系统 shell 执行,没有沙箱、没有白名单。也就是说:默认安装等于给模型一个真实的、完整权限的终端。
上一章那 5,802 行的红线代码不能替代这一层。它拦的是「一眼看去就是灾难」的命令,拦不住一条精心构造的、或者通过合法工具组合达成的破坏。
base.py 有 68.3 KB —— 它不只是一个接口定义,还包含了所有环境共用的实现。
class EnvironmentConnectionError(RuntimeError):
"""Infrastructure/connection-class failure of a terminal backend."""
def __init__(self, reason: str, *, retry_hint: str = ""):
...
为什么要单独一个异常类型?因为需要区分两种失败:
| 失败类型 | 该怎么处理 |
|---|---|
| 命令本身失败 (编译错误、文件不存在) | 把错误信息返回给模型,让它自己想办法。这是正常的工作流程 |
| 环境连接失败 (容器没启动、SSH 断了、云沙箱超时) | 不是模型的问题。应该重试、或者告诉用户去检查基础设施 |
如果不区分,模型会收到「连接失败」并试图「修复」它 —— 但它根本无能为力,只会白白浪费几轮尝试。而 retry_hint(重试提示)字段说明这个异常还携带了「该怎么重试」的信息。
class _BoundedOutputCollector:
"""Retain a bounded 40/60 head-tail window of streamed text."""
def __init__(self, max_chars: int, spill_path: "Path | None" = None): ...
def _maybe_spill(self, text: str) -> None:
"""Tee ``text`` to the spill file (opened lazily on first overflow)."""
def close_spill(self) -> "str | None":
"""Close the spill file and return its path if it was used."""
def buffered_chars(self) -> int
def total_chars(self) -> int
def append(self, text: str) -> None
def render(self, *, suffix: str = "") -> str:
"""Render within ``max_chars``, preserving a required status suffix."""
一条命令可能输出几十万行(比如跑一个大项目的测试)。这些输出不能全部进上下文。
朴素做法:只留前 N 行,或者只留后 N 行。两种都有问题:
头尾窗口:保留开头 40%、结尾 60%,中间省略。这样两端的关键信息都在。
而且比例是不对称的(40/60 而不是 50/50)—— 因为结尾通常信息密度更高(错误汇总、失败列表、退出状态)。
_maybe_spill(溢出落盘)的设计也很实用:超出窗口的完整输出被写到一个文件里,而且是「第一次溢出时才惰性打开文件」。大多数命令输出很短,根本不会溢出 —— 那就完全不产生文件 I/O。真的溢出了,模型可以拿到文件路径去读全文。
render(suffix=...) 里那个「保留必需的状态后缀」也值得注意:无论怎么截断,「命令退出码是多少」这类状态信息必须保留。它们不能因为输出太长就被截掉。
def set_activity_callback(cb: Callable[[str], None] | None) -> None:
"""Register a callback that _wait_for_process fires periodically."""
def get_activity_callback() -> Callable[[str], None] | None:
"""Return the thread-local activity callback…"""
def touch_activity_if_due(...):
"""Fire the activity callback at most once every ``state['interval']`` seconds."""
一条命令可能跑几分钟(编译、测试、下载)。这段时间里:
touch_activity_if_due(到期才触发)就是节流器:最多每 N 秒触发一次回调。而且它是线程局部的 —— 因为多个工具可能在不同线程里并行执行,各自需要自己的回调。
def get_sandbox_dir() -> Path:
"""Return the host-side root for all sandbox storage (Docker workspaces, …)"""
所有沙箱的宿主机侧存储都在一个统一的根目录下。这样清理、备份、磁盘配额管理都有单一入口。
file_sync.py(20.2 KB)解决的是一个必然出现的问题:如果工具在容器/远程主机里执行,那么它读写的文件在哪里?
这一层的存在解释了为什么隔离是有成本的:不只是「启动容器慢」,还有持续的文件同步开销和一致性问题。这也是为什么本机模式是默认值 —— 它最快、最简单,代价是没有隔离。
环境是按会话/身份配置的,不是按单次工具调用。这个粒度选择有它的道理:
所以更合理的模式是:高信任场景(你自己的终端)用本机;低信任场景(公开 webhook、多用户群聊)用容器。而这个判断和第 4 章的工具集投放是同一个维度 —— 信任边界。
| 信任级别 | 工具集(第 4 章) | 执行环境(本章) |
|---|---|---|
| 高 你自己的终端 | 全量核心工具 | 本机(快) |
| 中 团队群聊 | 核心工具,可能去掉几个 | Docker 容器 |
| 低 公开 webhook | 只有 4 个只读工具 | 容器(即使工具已经很安全,也不给例外) |
两层是相乘的关系,不是二选一。工具集收窄减少了攻击面,执行隔离限制了攻击的后果。任何一层单独都不够。