跳到主要内容

Docker 沙箱执行:把危险工具关进容器 + 路径翻译

30 秒导读: agent 会调用 bash、改文件这类能破坏宿主机的工具。本章讲 Trae Agent 的一个可选防护:把这几个高危工具单独转发进一个 Docker 容器里跑,改文件也只改容器里挂载的那份 workspace,别的工具(如搜索、CKG)照旧在宿主跑。难点是容器里可能连 Python 都没有——它靠把编辑工具用 pyinstaller 编成独立二进制塞进去解决。

1. 这是什么(零基础也能懂)

一句话定义: 一层可选的隔离层——当你在命令行加上 --docker-image 之类的参数时,agent 就把 bash 和文件编辑工具的每一次调用,改成"在一个 Docker 容器里执行",而不是直接在你的机器上执行。

解决什么问题: LLM agent 会自己决定跑什么命令、改哪个文件。这很危险——一条 rm -rf 或一个走偏的路径就可能动到宿主机上不该动的东西。把这些动作关进容器,等于给 agent 一个一次性的、可丢弃的工作间:它在里面折腾,脏了直接销毁容器,宿主机不受影响。

关键取舍——不是全隔离,是"挑危险的隔离": 只有 3 个工具进容器,其余仍在宿主跑(见 §2)。这样搜索代码、查 CKG(代码知识图谱)这些只读、无害的操作不用付容器往返的代价。

用起来什么样: 命令行给一个容器来源即可,其它照常:

# 用一个现成镜像跑,任务结束后自动删容器
trae-cli run "修复这个 bug" --docker-image ubuntu:22.04 --docker-keep false

trae_agent/cli.py:145-174 定义了这组互斥的 Docker 开关:--docker-image / --docker-container-id / --dockerfile-path / --docker-image-file 四选一,外加 --docker-keep 决定跑完删不删容器。

一句话直觉: 把它想成"给 agent 的手套"——它伸手去动文件系统时,隔着容器这层手套,脏活干在手套里,脱下(删容器)就干净了。

2. 顶层全景(它大概怎么转)

Docker 模式的核心是一个装饰器(decorator)模式:真正干活的 ToolExecutor 被一个 DockerToolExecutor 包在外面,后者拦截工具调用,按工具名决定"转发进容器"还是"放行回宿主"。

怎么读下面这张图: 从上往下是一次工具调用的分流;左边是被隔离进容器的路径,右边是留在宿主的路径。

agent 决定调用某工具


┌───────────────────────────┐
│ DockerToolExecutor │ ← 装饰器,按工具名分流
│ (docker_tool_executor) │
└───────────────────────────┘
│ │
工具名 ∈ 白名单? 其余工具
bash / 编辑 / json编辑 (如 sequentialthinking、CKG)
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ _execute_in_docker │ │ 原始 ToolExecutor │
│ · 翻译 path │ │ 在宿主直接跑 │
│ · 拼容器内命令 │ └──────────────────┘
│ · DockerManager. │
│ execute() │
└────────────────────┘


┌────────────────────────────────────────┐
│ Docker 容器 │
│ · /workspace ← 挂载宿主 workspace_dir │
│ · /agent_tools ← pyinstaller 二进制 │
│ · 持久 bash shell(pexpect 驱动) │
└────────────────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件
DockerToolExecutor装饰器:按工具名分流,翻译路径,拼容器命令trae_agent/tools/docker_tool_executor.py
DockerManager管容器生命周期:建/连容器、挂 workspace、拷工具、跑命令、销毁trae_agent/agent/docker_manager.py
BaseAgent.__init__接线:有 docker_config 就用装饰器包住原执行器trae_agent/agent/base_agent.py:49-71
build_with_pyinstaller把编辑工具编成独立二进制,供容器内无 Python 也能跑trae_agent/cli.py:89-116
check_docker跑前体检:docker CLI 装了吗、daemon 活着吗trae_agent/cli.py:53-86

主线走一遍: CLI 拿到 Docker 参数 → 组出 docker_configcheck_docker 体检、首次用则 build_with_pyinstaller 打二进制 → BaseAgent.__init__DockerToolExecutor 包住原执行器 → execute_taskdocker_manager.start() 建容器挂 workspace → 每次工具调用经装饰器分流 → 任务结束按 docker_keep 决定 stop()

3. 核心原理(逐个机制,由浅入深)

3.1 接线:docker_config 存在就套一层装饰器

它要解决的小问题: 怎么在不改动任何工具本身代码的前提下,让部分工具改走容器?

思路: 用装饰器。原始 ToolExecutor(self._tools) 照建,然后如果有 docker_config,就把它塞进 DockerToolExecutor 里当 original_executor。对上层(agent 主循环,见 01-agent-loop)而言,self._tool_caller 的接口没变,感知不到有没有容器。

真实实现: trae_agent/agent/base_agent.py:48-73

# trae_agent/agent/base_agent.py:48-71(节选)
original_tool_executor = ToolExecutor(self._tools)
if docker_config:
tools_dir = os.path.join(project_root, "dist") # 打包好的二进制在这
self.docker_manager = DockerManager(...)
self._tool_caller = DockerToolExecutor(
original_executor=original_tool_executor, # 原执行器被包进去
docker_manager=self.docker_manager,
docker_tools=["bash", "str_replace_based_edit_tool", "json_edit_tool"], # 白名单
host_workspace_dir=docker_config.get("workspace_dir"),
container_workspace_dir=self.docker_manager.container_workspace,
)
else:
self._tool_caller = original_tool_executor

关键细节——白名单只有 3 个:

工具名干什么为何要隔离
bash跑任意 shell 命令最危险,能删文件、连网、跑任意程序
str_replace_based_edit_tool按字符串替换改文件会写宿主文件系统
json_edit_tool按 JSONPath 改 JSON 文件会写宿主文件系统

不在这三个里的工具(如 sequentialthinking、CKG 相关工具)永远在宿主跑——它们要么纯思考、要么只读,隔离没收益。这份白名单在 base_agent.py:68 硬编码。

3.2 DockerManager:容器从哪来、workspace 怎么进去

它要解决的小问题: "在容器里跑"——可这个容器从哪冒出来?agent 改的文件又怎么让宿主看得到?

镜像/容器有 4 种来源(互斥,cli.py:226-259 保证只选一个):

来源docker_configstart() 里怎么处理
现成镜像名image直接 containers.run 起新容器
已存在的容器 IDcontainer_idcontainers.get 附着,标记 _is_managed=False(不归它管、不销毁)
Dockerfile 路径dockerfile_path现场 images.build 编个临时镜像再起容器
镜像 tar 文件docker_image_fileimages.load 从文件加载镜像再起容器

workspace 挂载是关键: 起新容器时,把宿主的 workspace_dir 以读写方式挂到容器内固定路径 /workspace。这样容器里改的文件,宿主机同一目录也立刻变——agent 的成果留得下来。

# trae_agent/agent/docker_manager.py:91-105(节选)
volumes = {
os.path.abspath(self.workspace_dir): {
"bind": self.container_workspace, # "/workspace"
"mode": "rw",
}
}
self.container = self.client.containers.run(
self.image, command="sleep infinity", # 容器空转待命
detach=True, volumes=volumes, working_dir=self.container_workspace,
)

command="sleep infinity" 让容器起来后什么都不干、只保持存活,等着 agent 一条条塞命令进去(见 §3.4 的持久 shell)。

生命周期由 agent 主循环掐两头: start() 在任务开始时调,stop() 在结束时按 docker_keep 决定调不调:

# trae_agent/agent/base_agent.py:151-152, 187-188(节选)
if self.docker_manager:
self.docker_manager.start() # execute_task 开头
...
finally:
if self.docker_manager and not self.docker_keep:
self.docker_manager.stop() # 结尾,且 docker_keep=False 才销毁

只销毁自己建的容器: stop() 里的 _is_managed 标志很重要——若用户是 --docker-container-id 附着到一个已存在的容器(docker_manager.py:87_is_managed=False),agent 就算 docker_keep=False不会去删它,只关自己起的持久 shell。见 docker_manager.py:147

3.3 _translate_path:宿主路径 → 容器内路径

它要解决的小问题: LLM 说"改 /home/me/proj/app.py",但这是宿主机的路径;容器里同一个文件挂在 /workspace/app.py。直接把宿主路径喂给容器内的工具会找不到文件。

思路: workspace 在宿主是 host_workspace_dir、在容器是 /workspace。所以只要判断某路径在 workspace 里,就把前缀换掉:抽出它相对 workspace 的部分,再拼到 /workspace 上。不在 workspace 里的路径原样返回(不翻译)。

原理演示(示意,非源码):

# 示意:宿主 workspace = /home/me/proj,容器 workspace = /workspace
def translate(host_path):
rel = relative_to(host_path, "/home/me/proj") # "app.py"
return join("/workspace", rel) # "/workspace/app.py"
# 重点看:只换前缀,相对结构不变;不在 workspace 内的路径不动

真实实现: trae_agent/tools/docker_tool_executor.py:35-47,函数 _translate_path。用 os.path.commonpath 判断 abs_host_path 是否落在 workspace 内,是则 relpath + join 重拼,否则原样返回。

谁触发翻译: _execute_in_docker 遍历工具参数,凡是叫 path 的字符串参数都翻译一遍docker_tool_executor.py:85-91)。这是个约定俗成的假设——参数名 path 即视为需翻译的路径。

3.4 _execute_in_docker:把工具调用变成容器内一条命令

它要解决的小问题: 工具调用是结构化的(工具名 + 一堆参数),可容器里只认 shell 命令字符串。怎么把前者翻成后者?

思路: 按工具名分三种拼法,拼出一条命令字符串,交给 DockerManager.execute 在容器的持久 shell 里跑。

三种拼法:

工具拼成的容器命令
bash直接就是 command 参数那串 shell 命令
str_replace_based_edit_tool/agent_tools/edit_tool <子命令> --key 'value' ...
json_edit_tool/agent_tools/json_edit_tool --key 'value' ...value 参数序列化成 JSON 串)

注意后两个调用的是 /agent_tools/ 下的独立二进制——这就是 pyinstaller 打包产物在容器内的落点(CONTAINER_TOOLS_PATH = "/agent_tools"docker_manager.py:16)。

真实实现: trae_agent/tools/docker_tool_executor.py:77-155,函数 _execute_in_docker。片段:

# trae_agent/tools/docker_tool_executor.py:113-125(编辑工具分支,节选)
executable_path = f"{self._docker_manager.CONTAINER_TOOLS_PATH}/edit_tool"
cmd_parts = [executable_path, sub_command]
for key, value in processed_args.items():
if key == "command" or value is None:
continue
...
cmd_parts.append(f"--{key} '{str(value)}'")
command_to_run = " ".join(cmd_parts)
...
exit_code, output = self._docker_manager.execute(command_to_run) # 进容器跑

拼好后 DockerManager.execute_execute_interactivedocker_manager.py:204-242):往 pexpect 驱动的持久 bash shell sendline,用一个 ---CMD_DONE---$? 标记回收退出码、清洗掉命令回显,把 (exit_code, output) 交回来。退出码 0 即 success=True

顺序 vs 并行: DockerToolExecutorparallel_tool_call 直接转调 sequential_tool_calldocker_tool_executor.py:70-75)——容器里那个单一持久 shell 是共享状态,并行会互相踩,所以一律串行。sequential_tool_call 里也是逐个分流:白名单进 _execute_in_docker,其余 await self._original_executor.sequential_tool_call([tool_call]) 回宿主(docker_tool_executor.py:57-68)。

3.5 为什么要 pyinstaller 打包编辑工具?

它要解决的小问题: 用户给的镜像可能是个极简系统——ubuntu:22.04 里连 Python 都没有,更别说 Trae Agent 的一堆依赖(jsonpath_ng 之类)。但编辑工具是用 Python 写的,怎么在这种容器里跑?

思路: 不指望容器有 Python。改用 PyInstaller(把 Python 程序连解释器一起打包成单个可执行文件的工具),在宿主上把编辑工具编成自带解释器和依赖的独立二进制,再 docker cp 拷进容器。容器里直接 ./edit_tool 就能跑,零运行时依赖。

打包做了什么: trae_agent/cli.py:89-116,函数 build_with_pyinstaller

# trae_agent/cli.py:92-108(节选)
subprocess.run(["pyinstaller", "--name", "edit_tool",
"trae_agent/tools/edit_tool_cli.py"], check=True)
subprocess.run(["pyinstaller", "--name", "json_edit_tool",
"--hidden-import=jsonpath_ng", # 显式带上运行时才 import 的依赖
"trae_agent/tools/json_edit_tool_cli.py"], check=True)
# 之后把产物拷进 trae_agent/dist/

它编译的不是工具原文件,而是两个专门的 CLI 包装trae_agent/tools/edit_tool_cli.pyjson_edit_tool_cli.py。这俩把工具逻辑重写成能命令行独立跑的形态——用 argparse 收参数,还内置了个空的 override 装饰器和极简 Tool 基类,好让代码脱离 Trae Agent 主包也能编译(见 edit_tool_cli.py 开头的注释:"我们不再需要它,所以定义一个什么都不做的函数")。

产物落点与拷贝链路:

宿主构建:build_with_pyinstaller
→ trae_agent/dist/{edit_tool, json_edit_tool, _internal}

│ base_agent 把 dist 当 tools_dir 交给 DockerManager

容器内:DockerManager._copy_tools_to_container
→ docker cp dist → 容器 /agent_tools/


_execute_in_docker 拼出 /agent_tools/edit_tool ... 调用

_copy_tools_to_containerdocker_manager.py:162-179)就是那句 docker cp:把宿主 dist/ 整个拷到容器的 /agent_toolstools_dirbase_agent.py:53 被指向 dist

只在首次构建: cli.py:272-275 检查 trae_agent/distdist/_internal 存在与否,不存在才 build_with_pyinstaller()——第一次用 Docker 模式会慢几秒打包,之后复用。

4. 巧妙之处(可借鉴的技术)

  1. 装饰器包执行器,零侵入工具。 不改任何一个 Tool 的实现,仅在执行器外套一层就实现了"部分工具改道"。上层主循环完全无感(base_agent.py:65-73)。

  2. 白名单式隔离,而非全隔离。 只把真正危险的写操作/shell 关进容器,只读操作留宿主,省掉大量无谓的容器往返(base_agent.py:68)。

  3. pyinstaller 打二进制破解"容器无 Python"。 让沙箱镜像可以极简,不必为跑 agent 的编辑工具而预装一堆依赖(cli.py:89-116 + 两个 *_cli.py 包装)。

  4. _is_managed 区分"我建的"和"我借的"容器。 附着到用户已有容器时绝不误删,只清理自己 run 出来的(docker_manager.py:87, 147)。

5. 边界与局限(诚实)

  • 不是全隔离。 白名单外的工具仍在宿主执行。若某个非白名单工具将来也能写文件系统,这层保护就有缺口。隔离范围硬编码在 base_agent.py:68

  • 路径翻译靠"参数名叫 path"的约定。 _execute_in_docker 只翻译名为 path 的参数(docker_tool_executor.py:87)。若某工具用别的参数名传路径(如 file_path),不会被翻译,可能在容器内路径错位。

  • 命令拼接靠单引号包裹。 --{key} '{str(value)}'docker_tool_executor.py:123)没有对含单引号的值做转义,含特殊字符的参数可能拼出错误命令(inferred,从拼接方式看出)。

  • 并行工具调用被降级为串行。 容器内共享单个持久 shell,parallel_tool_call 实际串行执行(docker_tool_executor.py:70-75),Docker 模式下拿不到并行加速。

  • Docker 不可用直接退出,不是静默降级。 跑前 check_dockercli.py:53-86)体检:docker CLI 没装、或 daemon 不通,cli.py:267-271 打印错误并 sys.exit(1)——不会回退到宿主执行。也就是说加了 Docker 参数就必须有可用 Docker,否则任务不启动。

6. 横向对比

同属本 shelf 的沙箱思路各有取舍:Trae Agent 这里是工具级、可选、白名单的隔离——粒度细、默认关、只挡写操作,用 pyinstaller 二进制适配极简镜像。这与"整个 agent 进程都跑在沙箱里"的粗粒度方案不同,代价是要维护路径翻译和命令拼接这层胶水。想了解不隔离时这些工具本身怎么跑,见 02-tools;容器生命周期如何嵌进主循环,见 01-agent-loop

7. 代码地图(导航索引)

主题文件路径符号名
装饰器接线(有 docker_config 就包一层)trae_agent/agent/base_agent.pyBaseAgent.__init__
容器生命周期在主循环的两端trae_agent/agent/base_agent.pyBaseAgent.execute_task
按工具名分流 + 逐个转发trae_agent/tools/docker_tool_executor.pyDockerToolExecutor.sequential_tool_call
宿主路径→容器路径翻译trae_agent/tools/docker_tool_executor.pyDockerToolExecutor._translate_path
工具调用拼成容器内命令并执行trae_agent/tools/docker_tool_executor.pyDockerToolExecutor._execute_in_docker
建/连容器、挂 workspacetrae_agent/agent/docker_manager.pyDockerManager.start
销毁托管容器(区分 _is_managedtrae_agent/agent/docker_manager.pyDockerManager.stop
拷 pyinstaller 二进制进容器trae_agent/agent/docker_manager.pyDockerManager._copy_tools_to_container
容器内持久 shell 执行命令trae_agent/agent/docker_manager.pyDockerManager._execute_interactive
打包编辑工具为独立二进制trae_agent/cli.pybuild_with_pyinstaller
Docker 可用性体检(不可用即退出)trae_agent/cli.pycheck_docker
编辑工具的 CLI 独立包装(打包源)trae_agent/tools/edit_tool_cli.py · trae_agent/tools/json_edit_tool_cli.py