主题
沙箱系统架构
本章目标:
- 讲清楚为什么 super agent 需要一层可插拔的隔离沙箱抽象,以及
Sandbox/SandboxProvider两层接口如何分工。- 拆解
LocalSandboxProvider(per-thread LocalSandbox + LRU 缓存)与AioSandboxProvider(Docker 隔离 + 温池)两套实现的生命周期差异。- 给出实现自定义
SandboxProvider的最小模板与必须遵守的抽象约束清单。
TL;DR
DeerFlow 把"agent 执行命令/读写文件"的能力抽象成 Sandbox 接口(7 个抽象方法),由 SandboxProvider 负责 acquire/get/release 生命周期管理。通过 config.sandbox.use 配置类路径,运行时用反射动态加载具体 provider 单例。内置 LocalSandboxProvider(直接在宿主机文件系统执行,per-thread 路径映射 + 256 容量 LRU 缓存)与 community 的 AioSandboxProvider(每线程一个 Docker 容器,真隔离 + 温池复用)。SandboxMiddleware 在 agent 生命周期中负责按需 acquire,把 sandbox_id 写进 ThreadState;security.py 默认禁止 LocalSandboxProvider 的宿主 bash 以收敛攻击面。
Overview
agent harness 的核心风险点是让 LLM 自由执行命令和读写文件。这天然要求一个可替换的隔离层:本地开发时希望直接用宿主机文件系统(零依赖、快、便于调试),生产/不可信场景则必须把执行关进容器(防止 LLM 误删宿主文件或越权访问)。如果把执行逻辑硬编码进工具层,就无法在不改工具代码的前提下切换隔离强度。
DeerFlow 的解法是两层抽象 + 反射加载:Sandbox 抽象"执行原语"(命令、文件、搜索),SandboxProvider 抽象"沙箱实例的生命周期"(谁创建、谁缓存、谁销毁)。工具层只面向接口编程,具体用哪种隔离完全由 config.sandbox.use 一行配置决定,运行时通过 resolve_class 反射实例化为单例 sandbox_provider.py:48-62。这样同一套工具代码在"本地直跑"与"Docker 隔离"两种模式下行为一致——两个实现都对外承诺统一的 /mnt/user-data/... 虚拟路径契约。
Architecture
整个沙箱系统由"抽象基类 + provider 单例工厂 + 中间件生命周期管理 + 安全门控"四块构成:
Sandbox(ABC):执行原语的契约,7 个@abstractmethod:execute_command/read_file/list_dir/write_file/glob/grep/update_file,每个沙箱实例带一个idsandbox.py:6-93。SandboxProvider(ABC):生命周期契约,3 个@abstractmethod:acquire(thread_id)/get(sandbox_id)/release(sandbox_id),外加可选reset();类属性uses_thread_data_mounts标记是否已通过挂载暴露线程数据目录 sandbox_provider.py:8-42。- 单例工厂:
get_sandbox_provider()用resolve_class(config.sandbox.use, SandboxProvider)动态加载并缓存进程级单例;reset_sandbox_provider()/shutdown_sandbox_provider()/set_sandbox_provider()分别用于配置变更、优雅关闭、测试注入 sandbox_provider.py:45-109。 SandboxMiddleware:把 provider 接入 agent 中间件链,负责按需 acquire 并把sandbox_id落进 ThreadState middleware.py:21-83。security.py:沙箱能力门控,默认禁止LocalSandboxProvider执行宿主 bash security.py:23-45。
Source 列表:
| 文件 | 角色 |
|---|---|
sandbox/sandbox.py | Sandbox 抽象基类(执行原语契约) |
sandbox/sandbox_provider.py | SandboxProvider 抽象基类 + 单例工厂 |
sandbox/local/local_sandbox_provider.py | 本地文件系统 provider(LRU 缓存) |
sandbox/local/local_sandbox.py | LocalSandbox + PathMapping 路径映射 |
community/aio_sandbox/aio_sandbox_provider.py | Docker 隔离 provider(温池 + 空闲回收) |
community/aio_sandbox/aio_sandbox.py | AioSandbox(HTTP 调容器 API) |
sandbox/middleware.py | SandboxMiddleware 生命周期管理 |
sandbox/security.py | 宿主 bash 能力门控 |
sandbox/exceptions.py | 结构化异常类型 |
Components / Subsystems
Sandbox 接口
职责:定义沙箱内可执行的最小操作集,屏蔽"本地直跑"与"远程容器"差异。所有方法都是 @abstractmethod,实现类必须全部实现。关键定义在 sandbox.py:18-93:execute_command(command) 返回 stdout/stderr 文本;read_file/write_file/list_dir/update_file 处理文件;glob/grep 返回 (结果, 是否截断) 二元组。基类只保存一个 _id 并暴露只读 id 属性 sandbox.py:9-16。
SandboxProvider
职责:管理沙箱实例的创建、查找与释放生命周期,并作为反射加载入口。acquire(thread_id) 返回 sandbox_id(字符串,非实例),get(sandbox_id) 才返回 Sandbox 实例——这种"id 与实例分离"设计让 id 可以安全地序列化进 ThreadState 并跨进程/跨中间件传递 sandbox_provider.py:13-38。reset() 是非抽象的可选钩子,供 provider 清理跨实例存活的模块级状态 sandbox_provider.py:40-42。
LocalSandboxProvider
职责:在宿主机文件系统直接执行,适合本地开发。关键类 local_sandbox_provider.py:34-329。要点:
- per-thread 沙箱:
acquire("abc")返回 id 为local:abc的LocalSandbox,其path_mappings把/mnt/user-data/{workspace,uploads,outputs}与/mnt/acp-workspace解析到该线程的宿主目录;acquire(None)保留 id 为local的遗留泛用单例 local_sandbox_provider.py:218-263。 - LRU 缓存:per-thread 实例存于
OrderedDict,默认上限 256,超出时淘汰最久未用条目;get()/acquire()命中时move_to_end提升热度 local_sandbox_provider.py:265-297。 - 路径映射:
LocalSandbox通过PathMapping把容器虚拟路径双向翻译成宿主真实路径,_resolve_path_with_mapping还会校验解析后路径未逃逸出挂载根目录(否则抛PermissionError)local_sandbox.py:123-148。 release是空操作:LocalSandbox 无外部资源,缓存实例跨轮次保留以维持_agent_written_paths反解提示,清理只走 LRU 淘汰或reset()/shutdown()local_sandbox_provider.py:299-328。
AioSandboxProvider
职责:每线程一个 Docker 容器,提供真隔离。关键类 aio_sandbox_provider.py:69-708。要点:
- 确定性 id:
sandbox_id由sha256(thread_id)[:8]推导,任何进程对同一线程得出同一容器名,无需共享内存即可跨进程发现 aio_sandbox_provider.py:239-246。 - 多层 acquire:进程内缓存 → 温池(容器仍在跑,免冷启动)→ 跨进程文件锁下 backend 发现/创建 aio_sandbox_provider.py:443-535。
- 温池语义:
release()不销毁容器而是放进_warm_pool供同线程下轮快速复用;真正销毁由replicas容量驱逐、空闲超时检查器或shutdown()触发 aio_sandbox_provider.py:623-708。 - 挂载注入:线程数据目录与 skills 目录在容器创建时 bind-mount 进同名虚拟路径,所以
AioSandbox本身不需要路径翻译;AioSandbox.execute_command用threading.Lock串行化对容器单一持久 shell 会话的调用 aio_sandbox.py:57-87。
SandboxMiddleware
职责:把 provider 接入 LangGraph agent 生命周期 middleware.py:21-83。默认 lazy_init=True:before_agent 不 acquire,推迟到首次工具调用时由 ensure_sandbox_initialized() 懒加载;lazy_init=False 时在 before_agent 提前 acquire 并写入 state["sandbox"]。沙箱在同一线程多轮间复用,不在每次 agent 调用后释放以避免重建浪费,清理依赖应用关闭时的 SandboxProvider.shutdown()。
security.py 与 exceptions.py
security.py 职责:能力门控。uses_local_sandbox_provider() 通过类路径标记判断当前是否本地 provider;is_host_bash_allowed() 规定:非本地 provider 一律放行,本地 provider 仅当 sandbox.allow_host_bash=true 才放行,否则返回禁用提示文案 security.py:23-45。is_local_sandbox(runtime) 从 ThreadState 读 sandbox_id,同时接受遗留 "local" 和 per-thread "local:..." 两种格式 tools.py:1006-1023。
exceptions.py 职责:结构化异常体系,SandboxError 为基类(带 message + details),派生 SandboxNotFoundError / SandboxRuntimeError / SandboxCommandError / SandboxFileError(及其子类 SandboxPermissionError / SandboxFileNotFoundError)exceptions.py:4-72。
Data Flow
下面是 lazy_init=True(默认)下一次完整的沙箱获取-执行-释放生命周期。注意:SandboxMiddleware.before_agent 在懒加载模式下不 acquire,真正的 acquire 发生在首个工具调用 ensure_sandbox_initialized() 内。
沙箱实例自身的生命周期状态机如下:
Implementation Details
LocalSandboxProvider.acquire 的双重检查 + LRU 关键片段(已精简)local_sandbox_provider.py:241-263:
python
# 锁内快路径:命中缓存即提升 LRU 热度并返回
with self._lock:
cached = self._thread_sandboxes.get(thread_id)
if cached is not None:
self._thread_sandboxes.move_to_end(thread_id)
return cached.id
# 释放锁做文件 I/O(ensure_thread_dirs)
new_mappings = list(self._path_mappings) + self._build_thread_path_mappings(thread_id)
with self._lock:
# 锁外 I/O 期间可能被其他线程抢先,重新检查
cached = self._thread_sandboxes.get(thread_id)
if cached is None:
cached = LocalSandbox(f"local:{thread_id}", path_mappings=new_mappings)
self._thread_sandboxes[thread_id] = cached
self._evict_until_within_cap_locked() # 超 256 则淘汰最旧
else:
self._thread_sandboxes.move_to_end(thread_id)
return cached.id解读:这是经典的"锁外做慢 I/O、锁内做快缓存操作"双重检查模式。_build_thread_path_mappings 会调 ensure_thread_dirs 触碰文件系统,必须在锁外执行避免阻塞其他线程;锁外 I/O 后重新检查是因为可能有并发 caller 已为同一 thread_id 建好沙箱,保证"同 thread_id 永远观察到同一实例"。_evict_until_within_cap_locked 在持锁状态下用 popitem(last=False) 弹出最久未用条目实现 LRU local_sandbox_provider.py:265-276。
速查表
LocalSandbox 与 AioSandbox 能力/隔离对比矩阵:
| 维度 | LocalSandbox / LocalSandboxProvider | AioSandbox / AioSandboxProvider | Source |
|---|---|---|---|
| 执行位置 | 宿主机进程(subprocess) | Docker 容器内 HTTP API | local_sandbox.py:307-352 / aio_sandbox.py:57-87 |
| 隔离强度 | 无进程/文件系统隔离 | 容器级隔离 | security.py:10-20 |
| sandbox_id 格式 | local 或 local:{thread_id} | sha256(thread_id)[:8] | local_sandbox_provider.py:218-258 / aio_sandbox_provider.py:239-246 |
| 路径处理 | PathMapping 双向翻译 + 逃逸校验 | 容器内 bind-mount,无需翻译 | local_sandbox.py:123-148 / aio_sandbox_provider.py:265-284 |
| 缓存/复用 | 256 容量 LRU OrderedDict | 进程内缓存 + 温池 + 跨进程发现 | local_sandbox_provider.py:265-297 / aio_sandbox_provider.py:443-535 |
| release 行为 | 空操作(保留缓存) | 放进温池,容器继续运行 | local_sandbox_provider.py:299-308 / aio_sandbox_provider.py:623-647 |
| 空闲回收 | 无(仅 LRU 淘汰) | 后台 idle checker,默认 600s | aio_sandbox_provider.py:318-374 |
| 宿主 bash | 默认禁止(需 allow_host_bash) | 默认放行(容器内安全) | security.py:35-45 |
uses_thread_data_mounts | True(类属性) | 仅本地容器后端为 True | local_sandbox_provider.py:65 / aio_sandbox_provider.py:123-131 |
扩展指南
要接入自定义隔离后端(例如 gVisor、远程 VM、WebContainer),实现一个 SandboxProvider 子类与一个 Sandbox 子类,再把类路径写进 config.sandbox.use 即可,无需改动任何工具代码。
最小模板:
python
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
from deerflow.sandbox.search import GrepMatch
class MySandbox(Sandbox):
def execute_command(self, command: str) -> str: ...
def read_file(self, path: str) -> str: ...
def list_dir(self, path: str, max_depth=2) -> list[str]: ...
def write_file(self, path: str, content: str, append: bool = False) -> None: ...
def glob(self, path: str, pattern: str, *, include_dirs: bool = False,
max_results: int = 200) -> tuple[list[str], bool]: ...
def grep(self, path: str, pattern: str, *, glob: str | None = None,
literal: bool = False, case_sensitive: bool = False,
max_results: int = 100) -> tuple[list[GrepMatch], bool]: ...
def update_file(self, path: str, content: bytes) -> None: ...
class MySandboxProvider(SandboxProvider):
uses_thread_data_mounts = False # 若已挂载线程数据目录则设 True
def acquire(self, thread_id: str | None = None) -> str: ... # 返回 sandbox_id 字符串
def get(self, sandbox_id: str) -> Sandbox | None: ... # 返回 Sandbox 实例
def release(self, sandbox_id: str) -> None: ...
# 可选:def reset(self) -> None / def shutdown(self) -> None约束清单(从抽象基类 @abstractmethod 读出,缺一个就无法实例化):
Sandbox必须实现全部 7 个抽象方法:execute_command/read_file/list_dir/write_file/glob/grep/update_file,且通过super().__init__(id)设置idsandbox.py:11-93。SandboxProvider必须实现 3 个抽象方法:acquire/get/release;reset()非抽象但建议覆写以清理跨实例存活状态 sandbox_provider.py:13-42。acquire必须返回字符串 id而非实例;get(id)必须对相同 id 返回同一(或等价)Sandbox实例;get找不到时返回None(而非抛异常)。acquire/get/release可能被多线程并发调用(Gateway 工具分发、subagent 工作池、后台 memory updater),需自行加锁保证线程安全。- 若实现支持优雅关闭,提供
shutdown()方法——shutdown_sandbox_provider()通过hasattr探测并调用 sandbox_provider.py:86-97。 - 类必须可被
resolve_class加载并通过isinstance(cls, SandboxProvider)校验,故必须直接或间接继承SandboxProvidersandbox_provider.py:58-62。
Configuration
沙箱配置位于 config.yaml 的 sandbox 段,schema 见 sandbox_config.py:12-83:
| 配置项 | 默认 | 说明 | Source |
|---|---|---|---|
use | (必填) | provider 类路径,反射加载 | sandbox_config.py:30-33 |
allow_host_bash | false | ⚠️ 安全敏感:允许 LocalSandboxProvider 在宿主机直接执行 bash。开启等于把宿主机暴露给 LLM,仅限完全可信的本地环境,生产/不可信场景务必保持 false(默认禁用提示见 security.py:10-20) | sandbox_config.py:34-37 |
image | AIO 默认镜像 | AioSandboxProvider 容器镜像 | sandbox_config.py:38-41 |
port | 8080 | AIO 容器基础端口 | sandbox_config.py:42-45 |
replicas | 3 | AIO 最大并发容器数,超限 LRU 驱逐温池 | sandbox_config.py:46-49 |
idle_timeout | 600 | AIO 空闲秒数后回收,0 关闭 | sandbox_config.py:54-57 |
mounts | [] | ⚠️ 安全敏感:host↔container 卷挂载;LocalSandboxProvider 会拒绝与 skills//mnt/user-data//mnt/acp-workspace 等保留前缀冲突的挂载 | sandbox_config.py:58-61 |
environment | {} | 注入容器的环境变量,$ 前缀从宿主 env 解析 | sandbox_config.py:62-65 |
bash_output_max_chars | 20000 | bash 工具输出上限(中段截断) | sandbox_config.py:67-71 |
config.sandbox.use 变更后需调 reset_sandbox_provider()(触发 provider 的 reset())才能让新配置在下次 acquire() 生效 sandbox_provider.py:65-83。
Common Pitfalls/Tips
- 别把 LocalSandboxProvider 当安全边界:它直接在宿主机跑 subprocess,没有任何隔离。
security.py默认禁宿主 bash 就是这个原因;不可信场景一律用AioSandboxProvidersecurity.py:10-20。 release不等于销毁:两种内置 provider 的release都不真正回收(Local 空操作、Aio 进温池),SandboxMiddleware也刻意不在每轮后释放——真正清理靠shutdown()或空闲/LRU 驱逐 middleware.py:24-29。acquire返回 id 不是实例:务必再get(sandbox_id)拿实例;ensure_sandbox_initialized()已封装这个两步流程,工具层直接用它即可 tools.py:1098-1107。- LRU 淘汰会丢
_agent_written_paths:LocalSandbox 被驱逐后重建,只损失 read_file 的反向路径解析提示,行为退化为全新运行,不影响正确性 local_sandbox_provider.py:24-31。 is_local_sandbox要兼容两种 id:判断时既要认"local"也要认"local:"前缀,直接复用is_local_sandbox(runtime)不要自己拼字符串 tools.py:1006-1023。- 改配置后记得 reset:仅改
config.yaml不会自动换 provider 单例,需显式reset_sandbox_provider()sandbox_provider.py:65-83。
References
- backend/packages/harness/deerflow/sandbox/sandbox.py —
Sandbox抽象基类 - backend/packages/harness/deerflow/sandbox/sandbox_provider.py —
SandboxProvider与单例工厂 - backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py — 本地 provider + LRU
- backend/packages/harness/deerflow/sandbox/local/local_sandbox.py —
LocalSandbox+PathMapping - backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox_provider.py — Docker 隔离 provider
- backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py —
AioSandboxHTTP 实现 - backend/packages/harness/deerflow/sandbox/middleware.py —
SandboxMiddleware生命周期 - backend/packages/harness/deerflow/sandbox/security.py — 宿主 bash 门控
- backend/packages/harness/deerflow/sandbox/exceptions.py — 结构化异常
- backend/packages/harness/deerflow/config/sandbox_config.py — 配置 schema
Related Pages
| 章节 | 关系 |
|---|---|
| ./07-沙箱与工具配置.md | config.sandbox 配置在该章详述,本章聚焦运行时架构 |
| ./18-沙箱工具与路径映射.md | bash/read/write 等工具与虚拟路径翻译细节(本章留给 18 章) |
| ./12-中间件链机制.md | SandboxMiddleware 在完整中间件链中的位置与执行顺序 |
| ./19-子代理委派系统.md | subagent 工作池并发调用 provider,依赖本章的线程安全保证 |