6 · 执行环境

tools/environments/,12 个文件。这一章讲工具实际在哪里跑 —— 也就是纵深防御里唯一真正的硬边界。

6.1 七种可选环境

环境大小隔离程度与用途
local.py
本机
91.9 KB 默认。⚠️ 无沙箱。命令直接交给宿主机的 shell 执行。速度最快,但智能体拥有和你完全相同的权限
docker.py
容器
91.2 KB 在 Docker 容器里执行。文件系统、进程、网络都被隔离。这是最常见的生产选择
modal.py
managed_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,宿主机与环境之间的文件同步)。

6.2 必须诚实说明的一件事

2026 年 4 月的第三方安全审计

审计检查了约 36.4 万行代码。结论是:没有发现恶意代码、后门或隐藏的数据上报

但同时报告了 4 个「严重」(critical)级别、9 个「高」(high)级别的架构问题。头号问题是:

在默认的本机后端下,terminal 工具把命令直接交给系统 shell 执行,没有沙箱、没有白名单。也就是说:默认安装等于给模型一个真实的、完整权限的终端。

上一章那 5,802 行的红线代码不能替代这一层。它拦的是「一眼看去就是灾难」的命令,拦不住一条精心构造的、或者通过合法工具组合达成的破坏。

6.3 抽象基类里的共用逻辑

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."""
「40/60 头尾窗口」是什么意思

一条命令可能输出几十万行(比如跑一个大项目的测试)。这些输出不能全部进上下文。

朴素做法:只留前 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, …)"""

所有沙箱的宿主机侧存储都在一个统一的根目录下。这样清理、备份、磁盘配额管理都有单一入口。

6.4 文件同步

file_sync.py(20.2 KB)解决的是一个必然出现的问题:如果工具在容器/远程主机里执行,那么它读写的文件在哪里?

场景:你的代码在本机 ~/myproject/ 智能体的 terminal 工具跑在 Docker 容器里 模型说:"读一下 src/main.py" ↓ 容器里没有这个文件 —— 除非挂载或同步进去 模型说:"把这个文件改成……" ↓ 改的是容器里的副本。你在本机看不到变化 —— 除非同步回来 → 需要一层双向文件同步

这一层的存在解释了为什么隔离是有成本的:不只是「启动容器慢」,还有持续的文件同步开销和一致性问题。这也是为什么本机模式是默认值 —— 它最快、最简单,代价是没有隔离。

6.5 环境的选择时机

环境是按会话/身份配置的,不是按单次工具调用。这个粒度选择有它的道理:

  • 如果按单次调用切换环境,那么「先写个文件、再读它」这样的连续操作会跨环境失效
  • 而且每次切换都有启动开销

所以更合理的模式是:高信任场景(你自己的终端)用本机;低信任场景(公开 webhook、多用户群聊)用容器。而这个判断和第 4 章的工具集投放是同一个维度 —— 信任边界

两层防护的正确组合
信任级别工具集(第 4 章)执行环境(本章)

你自己的终端
全量核心工具本机(快)

团队群聊
核心工具,可能去掉几个Docker 容器

公开 webhook
只有 4 个只读工具容器(即使工具已经很安全,也不给例外)

两层是相乘的关系,不是二选一。工具集收窄减少了攻击面,执行隔离限制了攻击的后果。任何一层单独都不够。

6 · Execution Environments

tools/environments/, 12 files. This chapter is about where tools actually run — which is the only truly hard boundary in the defense-in-depth stack.

6.1 Seven Environments to Choose From

EnvironmentSizeDegree of isolation and purpose
local.py
Local
91.9 KB The default. ⚠️ No sandbox. Commands are handed straight to the host machine's shell. Fastest, but the agent has exactly the same permissions you do
docker.py
Container
91.2 KB Runs inside a Docker container. Filesystem, processes, and network are all isolated. The most common production choice
modal.py
managed_modal.py
16.9 + 9.7 KB Modal cloud sandbox. Starts on demand, costs nothing while idle. Suited to setups where “the agent is asleep most of the time”
vercel_sandbox.py 21.0 KB Vercel's sandbox service
daytona.py 9.8 KB Daytona remote development environments
singularity.py 10.0 KB Singularity containers — common on high-performance computing clusters (university and research-lab GPU clusters usually don't grant Docker permissions, only Singularity)
ssh.py 17.1 KB Executes on another host over SSH

Plus two supporting modules: base.py (68.3 KB, the abstract base class and shared logic) and file_sync.py (20.2 KB, file synchronization between the host and the environment).

6.2 One Thing That Has to Be Said Plainly

The third-party security audit of April 2026

The audit examined about 364,000 lines of code. Its conclusion: no malicious code, backdoors, or hidden data exfiltration were found.

But it also reported 4 “critical” and 9 “high” severity architectural issues. The number one issue:

Under the default local backend, the terminal tool hands commands directly to the system shell, with no sandbox and no allowlist. In other words: a default install hands the model a real, fully privileged terminal.

The 5,802 lines of red-line code from the previous chapter cannot substitute for this layer. They stop commands that are “obviously a disaster at a glance”; they cannot stop a carefully constructed one, or damage achieved by combining legitimate tools.

6.3 Shared Logic in the Abstract Base Class

base.py is 68.3 KB — it isn't just an interface definition; it also contains the implementation shared by every environment.

A dedicated exception for connection-class failures

class EnvironmentConnectionError(RuntimeError):
    """Infrastructure/connection-class failure of a terminal backend."""

    def __init__(self, reason: str, *, retry_hint: str = ""):
        ...

Why a separate exception type? Because two kinds of failure need to be told apart:

Kind of failureHow to handle it
The command itself failed
(compile error, file not found)
Return the error to the model and let it work something out. This is the normal workflow
The environment connection failed
(container not started, SSH dropped, cloud sandbox timed out)
Not the model's problem. Retry, or tell the user to check the infrastructure

Without the distinction, the model receives “connection failed” and tries to “fix” it — but there's nothing it can do, and it just burns a few rounds of attempts. The retry_hint field shows that the exception also carries information about “how to retry.”

The bounded output collector

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."""
What a “40/60 head-tail window” means

A single command can produce hundreds of thousands of lines (running a large project's test suite, say). All of that cannot go into the context.

The naive approaches: keep only the first N lines, or only the last N lines. Both have problems:

  • Keep only the head → you lose the most important part, the error summary and exit code (usually at the end)
  • Keep only the tail → you lose which step things started going wrong at (that's at the beginning)

The head-tail window: keep the first 40% and the last 60%, and elide the middle. The key information at both ends survives.

And the split is asymmetric (40/60 rather than 50/50) — because the tail is usually denser in information (error roll-ups, failure lists, exit status).

The design of _maybe_spill is also very practical: the full output beyond the window is written to a file, and the file is “opened lazily on the first overflow.” Most command output is short and never overflows — so no file I/O happens at all. When it really does overflow, the model gets the file path and can read the whole thing.

The “preserving a required status suffix” in render(suffix=...) also deserves attention: however the output is truncated, status information like “what was the command's exit code” must survive. It can't get cut off just because the output was too long.

The activity callback: a heartbeat for long commands

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."""

A command may run for minutes (compiling, testing, downloading). During that time:

  • The user needs to know “still running, not dead”
  • The gateway needs to update the status message in the chat window
  • But not every second — that spams the channel and trips the platform's rate limits

touch_activity_if_due is the throttle: fire the callback at most once every N seconds. And it is thread-local — because several tools may be executing in parallel on different threads, and each needs its own callback.

The sandbox directory

def get_sandbox_dir() -> Path:
    """Return the host-side root for all sandbox storage (Docker workspaces, …)"""

All host-side sandbox storage lives under one root directory. That gives cleanup, backup, and disk-quota management a single entry point.

6.4 File Synchronization

file_sync.py (20.2 KB) solves a problem that is bound to come up: if tools execute inside a container or on a remote host, where are the files they read and write?

Scenario: your code is on your machine at ~/myproject/ the agent's terminal tool runs inside a Docker container The model says: "read src/main.py" ↓ The container doesn't have that file — unless it's mounted or synced in The model says: "change this file to…" ↓ It changes the copy inside the container. You see no change on your machine — unless it's synced back → you need a layer of two-way file synchronization

The existence of this layer explains why isolation has a cost: not just “containers are slow to start,” but ongoing file-sync overhead and consistency problems. That is also why local mode is the default — it's the fastest and simplest, at the price of no isolation.

6.5 When the Environment Is Chosen

The environment is configured per session/identity, not per tool call. There's a reason for that granularity:

  • If the environment switched per call, a sequence like “write a file, then read it” would break across environments
  • And every switch carries startup overhead

So the more sensible pattern is: high-trust situations (your own terminal) run local; low-trust situations (public webhooks, multi-user group chats) run in a container. And that judgment is the same axis as the toolset exposure in Chapter 4 — the trust boundary.

The right way to combine the two layers of protection
Trust levelToolset (Chapter 4)Execution environment (this chapter)
High
Your own terminal
The full core toolsetLocal (fast)
Medium
Team group chat
Core tools, possibly minus a fewDocker container
Low
Public webhook
Only the 4 read-only toolsContainer (no exceptions, even though the tools are already safe)

The two layers multiply; they are not an either/or. Narrowing the toolset shrinks the attack surface; execution isolation limits the consequences of an attack. Neither layer is enough on its own.