第 6 篇:「给模型一个囚笼」—— 沙箱体系全解剖
第 6 篇:「给模型一个囚笼」—— 沙箱体系全解剖
系列:OpenManus 源码级深度解读(master @
3309bf4e416fb1c74b008f3e86494439a31bad53)
本篇覆盖:沙箱执行体系全章:本地 Docker 沙箱(app/sandbox/)+ 云沙箱双轨(app/daytona/+app/tool/sandbox/)
核心源码:app/sandbox/core/sandbox.py(462 行)、app/sandbox/core/terminal.py(346 行)、app/sandbox/core/manager.py(313 行)、app/sandbox/client.py(201 行)、app/daytona/sandbox.py(165 行)、app/agent/sandbox_agent.py(224 行)
阅读本文你将了解: 一个命令如何在资源受限、断网、带哨兵提示符的 Docker 囚笼里执行;文件为什么必须走 tar 流;管理器如何用三级锁+空闲回收治理上百个容器;以及 OpenManus 本地/云两条沙箱路线的真实分工——和它们各自没做完的事。
1. 还账篇:前三篇埋的所有安全债
这一篇是全系列的"还账"环节。前面几篇反复出现的危险信号,答案都藏在本篇:
- 03 篇第 5 节:
PythonExecute的safe_globals是假的——__import__没被拦,open()没被拦,模型写的 Python 代码就是宿主机权限; - 03 篇第 9 节:
Bash工具直接subprocess执行宿主机命令,唯一的"防御"是 sentinel 超时协议; - 05 篇第 8 节:MCP 服务端把 bash + editor 全权限暴露给外部 Agent。
OpenManus 对这批债的回答分两层:config.use_sandbox=True 时走本地 Docker 沙箱(app/sandbox/core/,基于 docker-py),另一条 SandboxManus 路线接 Daytona 云沙箱(app/daytona/,基于 Daytona SDK)。两条路线共享同一批 BaseTool 契约,但实现哲学完全不同:前者是"在同一台机器上造囚笼",后者是"把犯人送到别的机器上"。先上全景:

这张图把两条路线并排画出:本地路线是一条自下而上的完整栈——SANDBOX_CLIENT 单例指向 LocalSandboxClient 薄包装,真正的资源治理在 SandboxManager(多实例)与 DockerSandbox(单实例)两层,命令执行的最后一公里是 AsyncDockerizedTerminal/DockerSession 的 socket 会话;云端路线则把整条栈外包给 Daytona SDK,SandboxManus 只负责把 sb_* 四个工具装配进工具集合。两条路线在 Agent 眼里没有区别——都长成 BaseTool,这是前几篇反复出现的同构策略在安全领域的再现:隔离机制可以换,工具契约不换。
2. DockerSandbox:容器即囚笼
DockerSandbox 的 create()(app/sandbox/core/sandbox.py:49-103)是囚笼施工全过程,四个资源约束一口气打满:
# app/sandbox/core/sandbox.py:61-67(节选)
host_config = self.client.api.create_host_config(
mem_limit=self.config.memory_limit,
cpu_period=100000,
cpu_quota=int(100000 * self.config.cpu_limit),
network_mode="none" if not self.config.network_enabled else "bridge",
binds=self._prepare_volume_bindings(),
)
内存用 mem_limit(默认 512m,app/config.py:100),CPU 用 CFS 配额(cpu_period=100000 配 cpu_quota,即 1.0 核,app/config.py:101)——这是 Docker 资源限制的标准姿势,从 cgroup 层面硬限,模型再怎么写死循环也烧不穿。最关键的是 network_mode="none":沙箱默认断网(app/config.py:103-105 默认 False),模型在容器里写的代码没有出口流量,泄露数据和下载恶意包两条路同时堵死。
容器本身的启动方式也很讲究:command="tail -f /dev/null"(app/sandbox/core/sandbox.py:76)——用一个永远不退出的空转命令保持容器存活,真正的执行不走容器主进程,而是后面 terminal 的 exec 会话。命名用 sandbox_{uuid4().hex[:8]}(app/sandbox/core/sandbox.py:70)避免并发冲突。创建失败时 catch-all 调 self.cleanup() 再抛(app/sandbox/core/sandbox.py:101-103)——半成品容器不会泄漏,这个习惯和 01 篇的 finally 清理一脉相承。
卷映射里藏了一个容易看漏的细节:_ensure_host_dir(app/sandbox/core/sandbox.py:123-138)把宿主机侧的工作目录放在 tempdir 下随机命名(sandbox_{basename}_{os.urandom(4).hex()}),而不是复用配置里的路径。也就是说每次建沙箱,容器内 /workspace 对应的是宿主机一个临时新目录——任务结束后这个目录不会被主动删除(cleanup 只管容器),算是留给运维的一个清理尾巴。
3. 文件传输:为什么一切皆 tar
read_file/write_file/copy_from/copy_to 四个文件操作(app/sandbox/core/sandbox.py:166-375)全部构建在 Docker 的 get_archive/put_archive API 上——这个 API 只接受 tar 流,于是 DockerSandbox 里出现了一堆 tar 处理代码:_create_tar_stream 内存里现造 tar(app/sandbox/core/sandbox.py:377-394),_read_from_tar 把流落临时文件再解开(app/sandbox/core/sandbox.py:396-423),copy_from 还要区分"目标是文件还是目录"来决定解包策略(app/sandbox/core/sandbox.py:293-308)。
安全性靠 _safe_resolve_path(app/sandbox/core/sandbox.py:232-253):
# app/sandbox/core/sandbox.py:244-253(节选)
if ".." in path.split("/"):
raise ValueError("Path contains potentially unsafe patterns")
resolved = (
os.path.join(self.config.work_dir, path)
if not os.path.isabs(path)
else path
)
相对路径会被拼到 /workspace 下,含 .. 的路径段直接拒绝。但注意它的防护边界:绝对路径原样放行(else path)——模型传 /etc/passwd 是合法的,只是这发生在容器里,而容器已经被断网+资源限制关起来了。这体现了和 03 篇 StrReplaceEditor 完全不同的安全观:不追求路径白名单,把整个文件系统信任域缩小到容器内部。囚笼逻辑的第一性原理:与其逐条检查模型要摸什么,不如让它只能摸囚笼里的东西。
4. AsyncDockerizedTerminal:在囚笼里养一只常驻 bash
命令执行是全模块技术含量最高的部分。DockerSession.create(app/sandbox/core/terminal.py:31-73)用 Docker exec API 开一条 socket 连接,里面跑的是:
# app/sandbox/core/terminal.py:41-48
startup_command = [
"bash", "-c",
f"cd {working_dir} && "
"PROMPT_COMMAND='' "
"PS1='$ ' "
"exec bash --norc --noprofile",
]
三个动作都是为了让输出可预测:PROMPT_COMMAND='' 干掉每次提示符前可能插入的命令,PS1='$ ' 固定提示符为两字符哨兵,--norc --noprofile 跳过所有启动脚本。之后 _read_until_prompt(app/sandbox/core/terminal.py:117-137)就是"读到 $ 为止"的循环——socket 设成非阻塞(app/sandbox/core/terminal.py:67-69),EWOULDBLOCK 就 sleep(0.1) 再试,一个手写的异步轮询读取器。
execute(app/sandbox/core/terminal.py:139-216)的协议设计更精细:发命令时追加一行 echo $?(app/sandbox/core/terminal.py:159),读输出时逐行清洗——跳过命令回显行、跳过纯数字行(退出码)、以 $ 结尾判定完成(app/sandbox/core/terminal.py:162-193)。整条链路等价于"用一个文本协议模拟了 pty":没有 Docker attach 的多路复用复杂度,代价是如果命令输出里恰好有一行以 $ 结尾、或恰好是纯数字,就会被误吞。这是文本哨兵协议的经典缺陷,生产使用时输出清洗规则要按需加固。
安全侧 _sanitize_command(app/sandbox/core/terminal.py:218-248)是一个 7 条命令的黑名单:rm -rf /、mkfs、fork 炸弹、chmod -R 777 / 等。放在 03 篇的语境里看会觉得很眼熟——这就是 Bash 工具防御链的容器版,但黑名单匹配用的是 in command.lower() 子串匹配,误杀率高(echo "rm -rf /" 都会被拒)且明显拦不住变体。不过考虑到容器本身已断网+限资源,这层黑名单的实际意义是保护容器自己完成既定任务,而不是防模型越狱——真正的越狱防线在 cgroup 和 network=none 上。整体时序如下:

这张时序图完整走了一遍命令的旅程:Agent 发起调用后,请求经过单例 → 薄包装 → DockerSandbox → DockerSession 四层透传,最终以 {命令}\necho $? 的格式写进容器内常驻 bash 的 socket;中间的循环块强调非阻塞轮询这个实现要点(用户在 MT4/ZMQ 采集项目里验证过的同一教训:阻塞读会卡死事件循环,这里用 EWOULDBLOCK + sleep 规避);最后清洗、提取、原路返回。超时兜底画在右侧注释——asyncio.wait_for 到点抛 TimeoutError,由 DockerSandbox.run_command 转成 SandboxTimeoutError(app/sandbox/core/sandbox.py:161-164)。
5. SandboxManager:多沙箱管家的并发账本
单实例之外,SandboxManager(app/sandbox/core/manager.py:14-313)解决的是规模化问题:上限 100 个沙箱、3600 秒空闲回收、300 秒巡检周期(app/sandbox/core/manager.py:29-44)。并发控制是三级结构:全局锁管容量检查与登记(app/sandbox/core/manager.py:131-148)、每沙箱锁管单实例操作互斥(app/sandbox/core/manager.py:100-112)、活跃操作集合(_active_operations)给空闲回收当"正在用别动"的标记(app/sandbox/core/manager.py:107-112)。
生命周期靠一个后台 task 兜底:start_cleanup_task 起一个每 300 秒扫一遍 _last_used 的循环(app/sandbox/core/manager.py:174-185),超时且不在活跃集合的沙箱进回收列表(app/sandbox/core/manager.py:187-204)。这个设计有一个经典的时间窗口:_cleanup_idle_sandboxes 判断"不在活跃集合"和真正执行 delete_sandbox 之间没有原子性,理论上存在"刚检查完就有新操作进来"的竞态——_safe_delete_sandbox 用"等活跃操作最多 10×0.5 秒"(app/sandbox/core/manager.py:251-262)缓解而非根除。对 Agent 场景这个概率可接受,但这套代码如果被搬去多租户服务,回收竞态是第一个要修的点。
全量清理(进程退出时)也很克制:并发删所有沙箱但整体限时 30 秒(app/sandbox/core/manager.py:224-234),宁可留孤儿容器也不无限等——Docker 容器泄漏可以靠外部巡检兜底,进程卡死在退出路径上更糟。
6. client.py:第三处单例与一个没人用的 Protocol
app/sandbox/client.py 结构上是"接口两件套 + 实现一件套":SandboxFileOperations Protocol(app/sandbox/client.py:8-47)、BaseSandboxClient ABC(app/sandbox/client.py:50-83)、LocalSandboxClient 实现(app/sandbox/client.py:86-189)。LocalSandboxClient 是纯粹的转发层——每个方法都是"判空 + 透传"两行,它的存在价值是给未来的云端实现占住接口位。
但系列读到现在,真正值得注意的是最后一行:
# app/sandbox/client.py:192-201
def create_sandbox_client() -> LocalSandboxClient:
return LocalSandboxClient()
SANDBOX_CLIENT = create_sandbox_client()
模块级单例第三次出现。02 篇的 Config(进程级双检锁)、02 篇的 LLM(按名缓存)、这里是 SANDBOX_CLIENT(import 时即建)。三处的共同点是都在解决同一件事:让"逻辑上全局唯一"的资源不必层层传参。但前两处好歹有键(名字),可以直接 LLM["manus"] 取不同实例,SANDBOX_CLIENT 连键都没有——一个进程一个沙箱客户端,多任务并行时所有 Agent 共用同一个 Docker 容器,隔离性直接退化为"进程级"而非"任务级"。SandboxManager 的存在说明作者意识到了这个问题,但两条路径(client 单例 vs manager 多实例)之间没有打通的胶水层。
另外那个 SandboxFileOperations Protocol 定义了却没有被任何函数签名引用(全仓库无一处 isinstance 或类型标注用到它)——这是一段"设计先行、实现未跟上"的接口化石,和 03 篇 ToolResult 的 attachments 字段一样,记录了作者的意图而非现状。
7. 云端路线:Daytona 与 sb_* 工具族
SandboxManus(app/agent/sandbox_agent.py:21-224)走了完全不同的路:create() 工厂方法里先连 MCP 服务器、再 initialize_sandbox_tools(app/agent/sandbox_agent.py:63-70)。后者调 app/daytona/sandbox.py 的 create_sandbox(app/daytona/sandbox.py:102-147)在** Daytona 云端**开一台 2C/4G/5G 的沙箱,镜像里自带 Chrome + VNC(环境变量 RESOLUTION=1024x768x24、CHROME_DEBUGGING_PORT=9222,app/daytona/sandbox.py:117-129),拿到 VNC(6080 端口)和网站(8080 端口)两个预览链接挂到 sandbox_link(app/agent/sandbox_agent.py:83-99)。
对比本地路线,Daytona 的增益是清晰的:浏览器有了可视化窗口(VNC 可以人眼旁观 Agent 操作浏览器)、重依赖(Chrome/playwright)不占宿主机、auto_stop_interval=15 分钟自动停机控成本(app/daytona/sandbox.py:135)。代价也清晰:网络 RTT、API key 成本、以及 start_supervisord_session 里那行 time.sleep(25)(app/daytona/sandbox.py:95)——用睡眠等服务起动的朴素写法。
四个 sb_* 工具(app/tool/sandbox/)是这个云沙箱的能力出口,其中 SandboxShellTool 的设计明显比本地 Bash 工具更现代:命令默认非阻塞,跑在 tmux 会话里,action 枚举分 execute/check_output/terminate/list 四步(app/tool/sandbox/sb_shell_tool.py:28-75)——"启动长任务,稍后回来看输出"的工作流被做进了 schema(描述文案直说 “ideal for long-running operations”,app/tool/sandbox/sb_shell_tool.py:14)。这恰好是本地 Bash 工具 sentinel 协议想解决却没解决的问题。
不过 SandboxManus 自身有两处值得圈点的代码级问题。其一,self.sandbox = sandbox(app/agent/sandbox_agent.py:80)给一个未声明的字段赋值——BaseAgent 的 extra = "allow"(app/agent/base.py:47)让这行合法,但 sandbox 的类型信息从此脱离 Pydantic 校验,cleanup 里 self.sandbox.id(app/agent/sandbox_agent.py:195)拼错字段名时不会有任何静态期告警。其二,think() 里 SandboxBrowserTool().name(app/agent/sandbox_agent.py:207)每轮构造一个完整工具实例只为拿名字——name 是类属性,改成 SandboxBrowserTool.name 零成本。两处都不致命,但叠加在 187 行的 Agent 壳里,说明这条路线处于"能跑"而非"打磨"状态。
8. 关键源码事实
| # | 事实 | 出处 |
|---|---|---|
| 1 | 沙箱默认断网:network_mode="none" | app/sandbox/core/sandbox.py:65 + app/config.py:103-105 |
| 2 | 资源硬限:mem 512m / CFS 1.0 核 | app/sandbox/core/sandbox.py:61-67 + app/config.py:100-101 |
| 3 | 容器主进程是 tail -f /dev/null 占位 | app/sandbox/core/sandbox.py:76 |
| 4 | 宿主机工作目录是 tempdir 下随机目录 | app/sandbox/core/sandbox.py:123-138 |
| 5 | 文件读写全走 tar 流(Docker archive API) | app/sandbox/core/sandbox.py:166-394 |
| 6 | 路径防护只拦 ..,绝对路径放行 | app/sandbox/core/sandbox.py:244-253 |
| 7 | bash 以 --norc --noprofile + 固定 PS1='$ ' 启动 | app/sandbox/core/terminal.py:41-48 |
| 8 | 命令追加 echo $?,输出按行清洗提取退出码 | app/sandbox/core/terminal.py:159-202 |
| 9 | 危险命令黑名单仅 7 条,子串匹配 | app/sandbox/core/terminal.py:232-248 |
| 10 | Manager 三级并发结构:全局锁/沙箱锁/活跃集合 | app/sandbox/core/manager.py:88-112,131-148 |
| 11 | 空闲回收后台任务 300s 巡检 / 3600s 超时 | app/sandbox/core/manager.py:174-204 |
| 12 | SANDBOX_CLIENT 是模块级单例(第三处) | app/sandbox/client.py:201 |
| 13 | Daytona 云沙箱 2C4G + VNC 6080 + tmux 非阻塞 shell | app/daytona/sandbox.py:113-137 + app/tool/sandbox/sb_shell_tool.py:12-18 |
| 14 | SandboxManus 给未声明字段赋值(靠 extra=allow) | app/agent/sandbox_agent.py:80 + app/agent/base.py:47 |
9. 生产视角
镜像选择值得重审。默认镜像 python:3.12-slim(app/config.py:98)不含 git/编译工具链,模型第一个 pip install 带编译依赖的包就会失败——而沙箱断网,连重试都不可能。生产上要么换自带工具链的镜像,要么按任务类别准备镜像池(Manager 的 ensure_image 已支持按配置自动 pull,app/sandbox/core/manager.py:65-86)。
单例沙箱 vs 任务级沙箱。SANDBOX_CLIENT 单例意味着 main.py 单 Agent 场景没问题,但任何多 Agent 并行编排(run_flow.py)里两个 Agent 的文件会互相可见。建议以 SandboxManager.create_sandbox() 为主入口、按任务发放 sandbox_id,client 单例只留给快速实验。
文本哨兵协议要加护栏。PS1='$ ' 哨兵 + 纯数字行过滤在输出内容碰巧撞上规则时会静默丢数据。加固方向:命令包装成 printf '\x01MARK\x01'; cmd; printf '\x02MARK:%s\x02' "$?" 用不可见控制符做边界,清洗规则从"猜"变成"找标记"。
云沙箱的成本阀门。Daytona auto_stop_interval=15 分钟是好设计,但 time.sleep(25) 的启动等待(app/daytona/sandbox.py:95)在批量创建时是线性放大的延迟,应换成对 supervisord 端口的就绪轮询。
10. 小结与承上启下
沙箱体系可以浓缩成一句话:囚笼的强度取决于最弱一层,而 OpenManus 把最强的两层(cgroup 资源限流、network=none)放在了最外面。tar 流文件传输、文本哨兵终端、三级锁管理器都是在"容器已经关住了"这个前提下做的工程细化;本地与 Daytona 云端的双轨,则是一个务实的渐进路线——先保证能力可用(本地 Docker),再把重能力(浏览器+VNC)外包给专业服务。
到这里,前面五篇欠的安全债全部还清:PythonExecute 的假 safe、Bash 的全权限、MCP 服务端的暴露面,都有了一条明确的兜底路线。下一篇是全系列正文最后一篇:PlanningFlow——当单个 Agent 的 ReAct 循环不够用时,OpenManus 怎么在循环外面再套一层"计划-执行-复核"的编排结构,以及为什么 442 行的编排器最终只服务一条 30 行的工厂。
更多推荐



所有评论(0)