Skip to content

沙箱工具与路径映射

本章目标:

  1. 理解 bash / ls / read_file / write_file / str_replace 五个沙箱工具的职责边界与参数语义,以及它们如何统一对接抽象 Sandbox 接口。
  2. 掌握虚拟路径契约 /mnt/user-data/{workspace,uploads,outputs}/mnt/skills/mnt/acp-workspace 如何通过 replace_virtual_pathPathMapping 在 local / docker 两种实现下被统一翻译。
  3. 看清一次写文件调用从虚拟路径校验、路径翻译、按 (sandbox.id, path) 加锁串行化到最终落盘的完整链路。

TL;DR

DeerFlow 给 LLM 暴露一组以虚拟路径为唯一寻址方式的沙箱工具,所有真实主机/容器目录都被屏蔽在 /mnt/user-data/*/mnt/skills/mnt/acp-workspace 三类前缀之后。tools.py 在工具入口做安全校验(拒绝 ..、读写权限分级)与防御性翻译;LocalSandboxProvideracquire(thread_id) 时为每个线程构造 PathMapping 列表,让本地实现也能像 AIO Docker 那样原生接受虚拟路径。write_file / str_replace 通过 get_file_operation_lock(sandbox, path)(sandbox.id, 解析后路径) 维度串行化,避免同一文件并发写入互相覆盖。

Overview

为什么需要一层统一的虚拟路径契约,而不是直接把主机路径或容器路径交给 LLM?有三个根本原因。

第一是安全隔离。本地沙箱直接在 Gateway 进程的主机文件系统上执行命令,如果让模型自由使用绝对路径,就等于把整个磁盘暴露给 LLM。validate_local_tool_path 把可访问范围收敛到三类虚拟前缀,并对每个路径执行 _reject_path_traversal 拒绝 ..tools.py:576-582

第二是多实现一致性。系统有两种 Sandbox 实现:本地文件系统的 LocalSandbox 与基于 Docker 的 AioSandbox(provider 架构见第 17 章)。AIO 把宿主目录 volume-mount 到容器内的 /mnt/user-data 等路径,因此容器内进程天然看到虚拟路径;本地实现没有容器,必须靠 LocalSandboxProvider._build_thread_path_mappingsacquire 时为每个 thread_id 生成 PathMapping,让 LocalSandbox._resolve_path 把虚拟路径翻译成 {base_dir}/users/{user_id}/threads/{thread_id}/user-data/... 这样的真实主机路径 local_sandbox_provider.py:170-216。结果是:无论底层是 local 还是 docker,公开的 Sandbox API 都接受同一套 /mnt/user-data/... 路径。

第三是输出脱敏。本地实现解析出的真实主机路径绝不能回流给模型,否则会泄露宿主目录结构。mask_local_paths_in_output 在工具返回前把真实路径反向替换回虚拟前缀 tools.py:502-573

Architecture

虚拟路径系统由四层构成:工具入口(@tool 装饰的函数)、安全校验与翻译(tools.pyvalidate_* / replace_virtual_path*)、文件操作锁(file_operation_lock.py)、Sandbox 实现(LocalSandboxPathMapping 解析或 AIO 的容器挂载)。搜索能力(glob/grep)由 search.py 提供纯文件系统遍历。

Source 列表:

  • backend/packages/harness/deerflow/sandbox/tools.py — 五个工具定义、虚拟路径翻译、安全校验、输出脱敏
  • backend/packages/harness/deerflow/sandbox/file_operation_lock.py — 按 (sandbox.id, path) 维度的进程内文件锁
  • backend/packages/harness/deerflow/sandbox/search.pyfind_glob_matches / find_grep_matches 文件遍历与忽略规则
  • backend/packages/harness/deerflow/sandbox/sandbox.py — 抽象 Sandbox 接口
  • backend/packages/harness/deerflow/sandbox/local/local_sandbox.pyPathMapping 数据类与本地路径双向解析
  • backend/packages/harness/deerflow/sandbox/local/local_sandbox_provider.py — 每线程 PathMapping 构造与 LRU 缓存
  • backend/packages/harness/deerflow/sandbox/local/list_dir.pyls 的递归目录遍历
  • backend/packages/harness/deerflow/config/paths.py — 虚拟路径到主机目录的契约定义

Components / Subsystems

bash 工具

职责:在 Linux 环境执行 bash 命令,并对本地沙箱做路径安全校验与翻译。

关键流程:bash_toolensure_sandbox_initialized 拿到沙箱,若是本地沙箱且未通过 is_host_bash_allowed() 直接拒绝 tools.py:1242-1244。随后依次调用 validate_local_bash_command_paths(扫描命令中的绝对路径、file:// URL、.. 段)、replace_virtual_paths_in_command(把虚拟路径替换为真实路径)、_apply_cwd_prefix(前置 cd <workspace> && 把相对路径锚定到线程 workspace),最后执行并经 mask_local_paths_in_output + _truncate_bash_output 返回 tools.py:1247-1258validate_local_bash_command_paths 注释明确声明它只是 allow_host_bash 开关下的尽力防护,不是安全沙箱边界 tools.py:891-905

ls 工具

职责:以树形列出目录最多 2 层深度。

关键流程:ls_tool 对本地沙箱做只读校验 validate_local_tool_path(path, thread_data, read_only=True),然后按路径类别分支解析:skills 路径走 _resolve_skills_path,ACP 路径走 _resolve_acp_workspace_path,自定义挂载交给 LocalSandbox._resolve_path 处理,其余走 _resolve_and_validate_user_data_path tools.py:1289-1298。底层遍历由 list_dir 实现,max_depth 默认 2,符号链接逃逸出 root 会被跳过 list_dir.py:32-66

read_file 工具

职责:读取文本文件,支持可选的 start_line / end_line 行范围。

关键流程:与 ls 同样走只读校验与分类解析 tools.py:1463-1472。读出内容后,若同时给定 start_lineend_line,按 1-indexed 闭区间切片 content.splitlines()[start_line-1:end_line] tools.py:1476-1477。输出超出 read_file_output_max_chars(默认 50000)时头部截断,保留文件开头 tools.py:1179-1201

write_file 工具

职责:写文本到文件,默认覆盖,append=True 时追加到文件末尾,自动创建父目录。

关键流程:write_file_tool 对本地沙箱做读写校验(非只读)validate_local_tool_path(path, thread_data),非自定义挂载路径走 _resolve_and_validate_user_data_path 解析 tools.py:1518-1523。关键点:写入被包在 with get_file_operation_lock(sandbox, path):tools.py:1524-1525。底层 LocalSandbox.write_file 根据 append 选择 "a" / "w" 模式,并对只读挂载抛 EROFS local_sandbox.py:382-403

str_replace 工具

职责:在文件中把 old_str 替换为 new_str,replace_all=False(默认)时要求 old_str 恰好出现一次。

关键流程:校验解析与 write_file 相同。整个「读-改-写」三步被同一把 get_file_operation_lock(sandbox, path) 包住,保证原子性:读出 content,若 old_str 不在内容中返回错误,replace_all 决定 content.replace(old_str, new_str) 还是仅替换首个 ...new_str, 1),然后写回 tools.py:1568-1579

虚拟路径翻译:replace_virtual_path / replace_virtual_paths_in_command

replace_virtual_path 把单个 /mnt/user-data 路径替换为线程真实路径。它从 thread_dataworkspace_path / uploads_path / outputs_path 构建映射表,并在三者共享同一父目录时额外映射 /mnt/user-datatools.py:472-494。替换采用最长前缀优先并做段边界检查(path == virtual_basepath.startswith(virtual_base + "/")),避免 /mnt/user-data/workspace 误匹配 /mnt/user-data/workspace-extra tools.py:459-467

replace_virtual_paths_in_command 是命令字符串版本,依次用正则替换 skills、ACP workspace、user-data 三类前缀;自定义挂载路径留给 LocalSandbox._resolve_paths_in_command 处理 tools.py:933-978

PathMapping 与 LocalSandbox 双向解析

PathMapping(container_path, local_path, read_only) 不可变三元组 local_sandbox.py:15-21LocalSandboxProvider._setup_path_mappings 构建进程级静态映射(skills 目录只读 + config.yaml 自定义挂载),_build_thread_path_mappingsacquire(thread_id) 时追加每线程的 /mnt/user-data(及 workspace/uploads/outputs 三个子映射)与 /mnt/acp-workspace 映射 local_sandbox_provider.py:170-216_find_path_mappingcontainer_path 长度降序匹配,实现最长前缀优先;_resolve_path_with_mapping 解析后用 resolved_path.relative_to(local_root) 校验未逃逸出挂载目录,逃逸则抛 PermissionError local_sandbox.py:107-148_reverse_resolve_path 做反向(主机路径→虚拟路径)用于输出脱敏 local_sandbox.py:156-179

文件操作锁:同路径串行化

get_file_operation_lock(sandbox, path) 返回针对 (sandbox.id, path)threading.Lock。锁 key 由 get_file_operation_lock_key 计算:优先取 sandbox.id,无 id 时回退 instance:{id(sandbox)} file_operation_lock.py:13-17。锁表是 WeakValueDictionary,无引用时自动回收防止内存泄漏;表的读改写本身由 _FILE_OPERATION_LOCKS_GUARD 保护 file_operation_lock.py:8-27。锁作用域是 (sandbox.id, 解析后路径),因此不同隔离沙箱即使虚拟路径相同也不会在同一进程内互相争用。

search.py:glob / grep 遍历

find_glob_matchesfind_grep_matchesos.walk 遍历并按 IGNORE_PATTERNS(.gitnode_modules.venv 等)裁剪目录 search.py:7-57。grep 跳过符号链接、超 DEFAULT_MAX_FILE_SIZE_BYTES(1MB)的文件、二进制文件,并跳过超 line_summary_length*10 字符的行以防 ReDoS search.py:184-208glob_tool / grep_tool 通过 _resolve_local_read_path 做只读解析,结果经 mask_local_paths_in_output 脱敏 tools.py:1352-1360

Data Flow

下图展示一次 write_file("/mnt/user-data/workspace/a.py", ...) 在本地沙箱下的完整链路:虚拟路径校验 → 翻译 → 加锁 → 落盘。

Implementation Details

replace_virtual_path 的核心是最长前缀优先 + 段边界检查,保证路径替换既精确又不误伤相邻前缀:

python
# tools.py:459-469
for virtual_base, actual_base in sorted(mappings.items(), key=lambda item: len(item[0]), reverse=True):
    if path == virtual_base:
        return actual_base
    if path.startswith(f"{virtual_base}/"):
        rest = path[len(virtual_base):].lstrip("/")
        result = _join_path_preserving_style(actual_base, rest)
        if path.endswith("/") and not result.endswith(("/", "\\")):
            result += _path_separator_for_style(actual_base)
        return result
return path

解读:sorted(..., reverse=True) 让更具体的 /mnt/user-data/workspace 先于 /mnt/user-data 根被匹配。匹配条件用 path == virtual_basepath.startswith(virtual_base + "/") 而非裸 startswith(virtual_base),从而 /mnt/user-data/workspace 不会错误匹配 /mnt/user-data/workspace2_join_path_preserving_style 保留主机路径的分隔符风格(兼容 Windows 反斜杠)。无任何前缀命中时原样返回,使非虚拟路径(如已解析路径)透传。

文件锁的关键是「key 计算 + 弱引用表」:

python
# file_operation_lock.py:13-27
def get_file_operation_lock_key(sandbox: Sandbox, path: str) -> tuple[str, str]:
    sandbox_id = getattr(sandbox, "id", None)
    if not sandbox_id:
        sandbox_id = f"instance:{id(sandbox)}"
    return sandbox_id, path

def get_file_operation_lock(sandbox: Sandbox, path: str) -> threading.Lock:
    lock_key = get_file_operation_lock_key(sandbox, path)
    with _FILE_OPERATION_LOCKS_GUARD:
        lock = _FILE_OPERATION_LOCKS.get(lock_key)
        if lock is None:
            lock = threading.Lock()
            _FILE_OPERATION_LOCKS[lock_key] = lock
        return lock

解读:锁 key 是 (sandbox.id, path) 元组,因此「同一沙箱的同一文件」串行,而不同沙箱(不同 thread_id → 不同 local:{thread_id} id)即使虚拟路径字面相同也各持独立锁,不会跨线程互相阻塞。WeakValueDictionary 让没有任何持有者引用的锁被 GC 自动清理,避免长生命周期 Gateway 进程中锁表无限增长。

速查表

五个沙箱工具矩阵:

工具参数作用路径处理Source
bashdescription, command执行 bash 命令validate_local_bash_command_paths 校验绝对路径/file:///..;replace_virtual_paths_in_command 翻译;_apply_cwd_prefix 锚定 workspace;输出 mask_local_paths_in_outputtools.py:1227-1274
lsdescription, path树形列目录(≤2 层)只读校验;skills/ACP/user-data 分类解析;自定义挂载交 LocalSandbox;输出脱敏tools.py:1276-1321
read_filedescription, path, start_line?, end_line?读文本文件,可选行范围只读校验 + 分类解析;1-indexed 闭区间切片;头部截断 50000 字符tools.py:1443-1495
write_filedescription, path, content, append=False写文件(默认覆盖,可追加)读写校验 + _resolve_and_validate_user_data_path;get_file_operation_lock 串行化;appenda/w 模式tools.py:1498-1536
str_replacedescription, path, old_str, new_str, replace_all=False文件内子串替换读写校验 + 解析;锁内「读-改-写」原子;默认要求 old_str 唯一tools.py:1539-1587

Configuration

配置项作用Source
sandbox.bash_output_max_charsbash 输出最大字符数(默认 20000,中间截断)tools.py:1253-1258
sandbox.ls_output_max_charsls 输出最大字符数(默认 20000,头部截断)tools.py:1305-1312
sandbox.read_file_output_max_charsread_file 输出最大字符数(默认 50000,头部截断)tools.py:1478-1485
skills.container_pathskills 虚拟前缀(默认 /mnt/skills),静态映射为只读tools.py:81-98
sandbox.mounts[]自定义挂载 (host_path, container_path, read_only),不得与保留前缀冲突local_sandbox_provider.py:114-163
tools[].max_results(glob/grep)与请求值取较小值,glob 上限 1000、grep 上限 500tools.py:354-367

Common Pitfalls / Tips

  • write_file 默认覆盖:文档字符串明确写明 "By default this overwrites the target file";要保留原内容并追加必须显式传 append=true,该参数在模型可见的工具 schema 中暴露 tools.py:1498-1513
  • str_replace 默认要求唯一匹配:replace_all=False 时若 old_str 出现多次,仅替换首个(content.replace(old_str, new_str, 1));文档要求 old_str 应恰好出现一次,否则结果可能非预期 tools.py:1574-1577
  • skills / ACP workspace 只读:validate_local_tool_pathread_only=False(即 write_file/str_replace)时,对 /mnt/skills/mnt/acp-workspace 路径抛 PermissionError;它们只能被 ls/read_file/grep/glob 读取 tools.py:613-623
  • str_replace 对空文件直接返回 OK:读出内容为空时 if not content: return "OK",不会报错也不写入 tools.py:1569-1571
  • bash 本地模式可被禁用:本地沙箱下若 is_host_bash_allowed() 为假,bash 直接返回禁用提示,不执行任何命令 tools.py:1242-1244
  • LRU 驱逐丢失反解析提示:每线程 LocalSandbox 在 LRU 缓存中超过 256 上限会被驱逐,下次 acquire 重建,代价是丢失 _agent_written_paths(read_file 退化为不做反向路径替换,与全新运行一致) local_sandbox_provider.py:24-31

References

章节关系
17-沙箱系统架构.md上游:SandboxProvider 的 acquire/get/release 生命周期与 local/docker provider 整体架构,本章是其工具与路径映射细节
20-工具系统与内置工具.md平行:get_available_tools 如何把本章五个沙箱工具与内置/MCP/社区工具组合进 lead agent
27-文件上传与文档转换.md关联:上传文件落到 /mnt/user-data/uploads,由本章 read_file/ls 在虚拟路径下被代理读取

公司内部参考 · 由 claude-wiki-gen 基于源码自动生成的二次分析