第 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 节:PythonExecutesafe_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:容器即囚笼

DockerSandboxcreate()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=100000cpu_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_dirapp/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_pathapp/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.createapp/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_promptapp/sandbox/core/terminal.py:117-137)就是"读到 $ 为止"的循环——socket 设成非阻塞(app/sandbox/core/terminal.py:67-69),EWOULDBLOCKsleep(0.1) 再试,一个手写的异步轮询读取器。

executeapp/sandbox/core/terminal.py:139-216)的协议设计更精细:发命令时追加一行 echo $?app/sandbox/core/terminal.py:159),读输出时逐行清洗——跳过命令回显行、跳过纯数字行(退出码)、以 $ 结尾判定完成(app/sandbox/core/terminal.py:162-193)。整条链路等价于"用一个文本协议模拟了 pty":没有 Docker attach 的多路复用复杂度,代价是如果命令输出里恰好有一行以 $ 结尾、或恰好是纯数字,就会被误吞。这是文本哨兵协议的经典缺陷,生产使用时输出清洗规则要按需加固。

安全侧 _sanitize_commandapp/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 发起调用后,请求经过单例 → 薄包装 → DockerSandboxDockerSession 四层透传,最终以 {命令}\necho $? 的格式写进容器内常驻 bash 的 socket;中间的循环块强调非阻塞轮询这个实现要点(用户在 MT4/ZMQ 采集项目里验证过的同一教训:阻塞读会卡死事件循环,这里用 EWOULDBLOCK + sleep 规避);最后清洗、提取、原路返回。超时兜底画在右侧注释——asyncio.wait_for 到点抛 TimeoutError,由 DockerSandbox.run_command 转成 SandboxTimeoutErrorapp/sandbox/core/sandbox.py:161-164)。

5. SandboxManager:多沙箱管家的并发账本

单实例之外,SandboxManagerapp/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 篇 ToolResultattachments 字段一样,记录了作者的意图而非现状。

7. 云端路线:Daytona 与 sb_* 工具族

SandboxManusapp/agent/sandbox_agent.py:21-224)走了完全不同的路:create() 工厂方法里先连 MCP 服务器、再 initialize_sandbox_toolsapp/agent/sandbox_agent.py:63-70)。后者调 app/daytona/sandbox.pycreate_sandboxapp/daytona/sandbox.py:102-147)在** Daytona 云端**开一台 2C/4G/5G 的沙箱,镜像里自带 Chrome + VNC(环境变量 RESOLUTION=1024x768x24CHROME_DEBUGGING_PORT=9222app/daytona/sandbox.py:117-129),拿到 VNC(6080 端口)和网站(8080 端口)两个预览链接挂到 sandbox_linkapp/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 = sandboxapp/agent/sandbox_agent.py:80)给一个未声明的字段赋值——BaseAgentextra = "allow"app/agent/base.py:47)让这行合法,但 sandbox 的类型信息从此脱离 Pydantic 校验,cleanupself.sandbox.idapp/agent/sandbox_agent.py:195)拼错字段名时不会有任何静态期告警。其二,think()SandboxBrowserTool().nameapp/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
7bash 以 --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
10Manager 三级并发结构:全局锁/沙箱锁/活跃集合app/sandbox/core/manager.py:88-112,131-148
11空闲回收后台任务 300s 巡检 / 3600s 超时app/sandbox/core/manager.py:174-204
12SANDBOX_CLIENT 是模块级单例(第三处)app/sandbox/client.py:201
13Daytona 云沙箱 2C4G + VNC 6080 + tmux 非阻塞 shellapp/daytona/sandbox.py:113-137 + app/tool/sandbox/sb_shell_tool.py:12-18
14SandboxManus 给未声明字段赋值(靠 extra=allow)app/agent/sandbox_agent.py:80 + app/agent/base.py:47

9. 生产视角

镜像选择值得重审。默认镜像 python:3.12-slimapp/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 行的工厂。

Logo

AtomGit AI 社区提供模型库、数据集、Agent、Token等资源

更多推荐