Hotdry.

Article

Agent 文件工具:工作区根边界、realpath 规范化与符号链接逃逸

Coding Agent 与 MCP 的 read_file/write_file 常因字符串前缀判断、未解析符号链接或校验与打开之间的 TOCTOU 读到工作区外。本文给出 realpath/O_NOFOLLOW 可落地表、Python 与 openat 示意实现,以及硬链接、竞态与 OS 沙箱边界。

2026-07-15security

当 Agent 暴露 read_filewrite_filelist_dirapply_patch 或 MCP 等价工具时,平台进程会把模型生成的路径字符串映射到本机文件系统。与 HTTP 出站 SSRF、SQL 沙箱、shell 进程组不同,文件工具的主要故障模式往往是路径裁决错误:用 startswith(workspace) 做字符串比较、只规范化 .. 却不解析符号链接、或先 realpathopen 中间被替换。本文只讨论 用户态路径裁决与打开语义 的可验证行为与推荐参数,不覆盖 Landlock/seccomp 全量沙箱设计,也不讨论「是否应给 Agent 写权限」的产品决策。

问题背景:模型路径是不可信输入

典型数据流:

  1. 编排器向模型注册文件工具,参数为 path / offset / limit,描述中常写「仅限当前工作区」。
  2. 模型返回相对路径或绝对路径;Worker 拼到 workspace 根后调用语言运行时的 open/readFile
  3. 文件内容(或写回结果)序列化进上下文;错误信息中的绝对路径可能反喂模型,形成信息泄露通道。

在 Agent 场景下,下列因素会放大路径层边角:

  • .. 与多余分隔符foo/../../etc/passwdfoo//bar、Windows 风格 foo\..\bar(跨平台 Worker)会绕过朴素拼接。
  • 符号链接越界:工作区内合法文件 workspace/cache/link 指向 /etc/shadow 或其它租户目录;若只校验「路径字符串落在根下」而不解析最终目标,读工具会越权。
  • 硬链接与已打开 FD:同一 inode 可有多条目录项;仅靠路径前缀无法表达「数据是否属于本工作区」。
  • TOCTOUrealpath 判定安全后、open 之前,攻击者(或并行 tool 调用)把中间组件换成 symlink。
  • 大小写与 Unicode 归一化:在默认不区分大小写的卷(部分 macOS/Windows 挂载)上,Secret.envsecret.env 可能是同一文件;NFC/NFD 文件名也会让字符串相等判断失效。
  • 输出无界:即使路径正确,read_file 大日志或二进制误当文本会把数 MB 塞进下一轮 prompt。

因此需要把防御拆成:工作区根锚定规范化后的边界判断打开时的跟随策略读写配额与错误脱敏 四层。

可落地实现:锚定根、规范化与打开策略

1. 固定「逻辑根」与「物理根」

启动会话时解析一次工作区根,之后所有 tool 调用相对该根裁决:

概念 建议做法 说明
配置根 部署时给定绝对路径,如 /var/agent/runs/<run_id>/ws 禁止模型改写根;根本身不来自 tool 参数
物理根 realpath(配置根) 且要求目录存在 消除根路径上的 symlink 歧义
会话绑定 根写入 run 元数据,不放进可被模型编辑的配置文件 防止 Agent 改 .agent/config 把根指到 /
多根(MCP Roots) 每个 root 单独规范化后做并集判断 见 MCP Roots;仍须对每个候选路径做同一套校验

Python 示意:

from pathlib import Path

def bind_workspace(configured: str) -> Path:
    root = Path(configured).resolve(strict=True)  # 等价于跟随 symlink 的绝对物理路径
    if not root.is_dir():
        raise ValueError("workspace root must be a directory")
    return root

要点:strict=True(Python 3.6+ 的 resolve)要求路径存在;根不存在应启动失败,而不是静默落到 CWD。

2. 将模型路径解析为「候选绝对路径」再做边界判断

推荐算法(只读与写入共用):

  1. 拒绝空路径、NUL 字节、以及(在 POSIX Worker 上)意外的盘符形式(若业务不支持)。
  2. 若模型给相对路径:candidate = root / user_path;若给绝对路径:直接作为 candidate(随后仍用物理路径判断是否落在根下)。
  3. 规范化:对已存在路径用 realpath/Path.resolve(strict=True);对尚不存在的写路径,规范化父目录并拼接最终文件名(见下节)。
  4. 边界判断使用路径分量前缀,而不是字符串 startswith
def is_within(root: Path, target: Path) -> bool:
    try:
        target.relative_to(root)
        return True
    except ValueError:
        return False

错误示例与正确行为:

输入(相对 root=/ws 危险实现 正确结果
../etc/passwd 拼成字符串后 startswith("/ws") 误判 物理路径 /etc/passwd → 拒绝
docs/../../ws-secret(若 secret 在 /ws-secret startswith("/ws")/ws-secret 为真 relative_to 失败 → 拒绝
ok.txt → symlink 到 /etc/passwd 只看逻辑路径 /ws/ok.txt realpath 后越界 → 拒绝
subdir/new.txt(父存在) 要求整路径 realpath 失败 规范化父目录 /ws/subdir + new.txt

字符串前缀判断的经典缺陷:/workspace/workspace-evil/workspace2startswith("/workspace") 下均为真。必须用分量级 relative_to 或显式追加 os.sep 后再比较(仍推荐前者)。

3. 打开语义:跟随、不跟随与 openat

仅做用户态 realpath 不够,打开阶段应固定策略:

场景 推荐 依据
读已存在文件 规范化后 open(path, O_RDONLY | O_NOFOLLOW)(若策略为禁止最终组件为 symlink) open(2)O_NOFOLLOW 在最终组件为 symlink 时失败
允许最终组件 symlink,但目标必须仍在根内 realpath 再边界检查,然后 open;高威胁模型叠加 Landlock 跟随后目标可能越界
写新文件 openat(dirfd, name, O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW, mode) 避免覆盖意外 symlink;O_EXCL 防抢建
目录遍历 list_dir opendir/openat 目录 fd,子项名禁止再含 / 防止把「名字」当成相对路径二次拼接
禁止的文件系统对象 拒绝非常规文件(设备节点、FIFO、socket)除非显式白名单 fstat 后检查 S_ISREG

C 层示意(逻辑;生产需完整错误处理):

/* root_fd: 已打开的工作区根目录 O_DIRECTORY
 * rel: 相对根的相对路径,已拒绝绝对路径与 ".." 组件(或先做分量级 walk) */
int open_in_workspace(int root_fd, const char *rel, int flags) {
    /* 逐分量 openat(..., O_NOFOLLOW | O_DIRECTORY) walk,
     * 最后一分量用 flags | O_NOFOLLOW 打开常规文件 */
    /* 省略:walk 实现 */
    return -1;
}

Python 在标准库中没有完整暴露 openat 逐分量 walk;实务上常见两层:

  1. 快速路径resolve + relative_to + open(适合受信工作区、无本地攻击者)。
  2. 加固路径:用 os.opendir_fd,或依赖内核 Landlock / 容器挂载,使越界在 OS 层不可达。

4. 写入路径:父目录必须存在且在根内

模型常生成「尚未存在」的路径,整路径 realpath 会失败。推荐:

def resolve_for_write(root: Path, user_path: str) -> Path:
    raw = (root / user_path).resolve(strict=False)  # 规范化 .. 与中间 symlink(若可解析)
    parent = raw.parent
    if not parent.exists():
        raise FileNotFoundError("parent directory must exist")  # 或受控 mkdir
    parent = parent.resolve(strict=True)
    if not is_within(root, parent):
        raise PermissionError("path escapes workspace")
    final = parent / raw.name
    # 若 final 已存在,再 resolve 一次并检查 is_within
    if final.exists():
        final = final.resolve(strict=True)
        if not is_within(root, final):
            raise PermissionError("path escapes workspace")
    return final

参数建议:

参数 建议默认 说明
自动创建父目录 关,或仅允许深度 ≤ N 的 mkdir -p 且每级检查 盲目 mkdir -p 易配合 symlink 父目录逃逸
单文件最大读 256 KiB~1 MiB(可配置) 超限返回截断标志,勿静默截断无提示
单次写最大 与读同量级或更小 防止一次 tool 写爆磁盘
工作区磁盘配额 按 run 的 cgroup/quota 路径安全不替代容量安全
符号链接策略 默认:拒绝最终组件为 symlink 的写;读可选「跟随但目标须在根内」 写 symlink 本身应单独工具且默认关闭

5. 回灌与错误信息

  • 给模型的路径尽量用相对工作区的形式,避免泄露宿主机绝对前缀。
  • 拒绝越界时返回稳定错误码(如 path_out_of_workspace),不要附带「解析后真实目标是 /etc/passwd」。
  • 二进制文件:用魔数 / 扩展名 /NUL 比例检测,拒绝或改走 base64 且计入字节预算。

风险与边界

TOCTOU 无法仅靠一次 realpath 消除。 在共享工作区、多 Worker 并行、或 Agent 可自建 symlink 时,校验与 open 之间窗口真实存在。缓解:O_NOFOLLOW/O_EXCL、基于目录 fd 的 openat walk、将工作区挂载为仅本 run 可写、以及 Landlock 路径规则。用户态检查是必要但不充分条件。

硬链接越界。 若工作区内存在指向区外 inode 的硬链接(需要写权限与同一文件系统),realpath 仍落在根下的路径名上,但数据与区外文件共享。缓解:工作区使用独立文件系统 /bind mount、创建时禁止 link 系统调用(seccomp)、或 fstat 后对 st_dev 限制在允许设备集合。

.. 穿越挂载点。 resolve/realpath 会跨越 bind mount;若把敏感目录 bind 进工作区「只读参考树」,读工具会合法读到该内容 —— 这是配置问题,不是 bug。敏感只读树应只读挂载并审计其内容集合。

大小写折叠与 Unicode。 在 case-insensitive 或 Unicode 规范化的文件系统上,边界判断必须在同一文件系统语义下进行(打开后 fstat/fcntl 验证),不能假设字节串唯一。跨平台 Agent 应在文档中写明目标 FS 语义。

路径安全 ≠ 内容安全。 工作区内的 .env、密钥、客户上传文件仍会被合法 read_file 取走。需配合密钥扫描、目录级 deny list(如 .git/config 中的 token、.ssh)、以及工具级审批。路径边界只解决「别读到别的租户 / 主机文件」。

与 shell 工具的组合绕过。 若同一 Agent 同时拥有 run_terminal,模型可用 cat/ln -s 绕过文件工具策略。文件工具加固必须与 shell 进程隔离、seccomp、或「无 shell」配置一致;否则本文措施只降低无 shell 时的误配置与提示注入风险。

Windows 路径语义。 盘符、\\?\ 前缀、NTFS 交接点(junction)与 symlink 权限模型与 POSIX 不同。跨平台实现应分后端,而不是共用一套 startswith

MCP 与多 Roots。 多个 root 时,「落在任一 root 内」才放行;一个 root 内的 symlink 指向另一 root 通常可接受,指向 roots 之外必须拒绝。Roots 列表本身必须来自客户端 / 会话,不可由不可信 server 单方面扩大(见 MCP 安全实践)。

参考来源

security

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com