当 Agent 暴露 read_file、write_file、list_dir、apply_patch 或 MCP 等价工具时,平台进程会把模型生成的路径字符串映射到本机文件系统。与 HTTP 出站 SSRF、SQL 沙箱、shell 进程组不同,文件工具的主要故障模式往往是路径裁决错误:用 startswith(workspace) 做字符串比较、只规范化 .. 却不解析符号链接、或先 realpath 再 open 中间被替换。本文只讨论 用户态路径裁决与打开语义 的可验证行为与推荐参数,不覆盖 Landlock/seccomp 全量沙箱设计,也不讨论「是否应给 Agent 写权限」的产品决策。
问题背景:模型路径是不可信输入
典型数据流:
- 编排器向模型注册文件工具,参数为
path/offset/limit,描述中常写「仅限当前工作区」。 - 模型返回相对路径或绝对路径;Worker 拼到 workspace 根后调用语言运行时的
open/readFile。 - 文件内容(或写回结果)序列化进上下文;错误信息中的绝对路径可能反喂模型,形成信息泄露通道。
在 Agent 场景下,下列因素会放大路径层边角:
..与多余分隔符:foo/../../etc/passwd、foo//bar、Windows 风格foo\..\bar(跨平台 Worker)会绕过朴素拼接。- 符号链接越界:工作区内合法文件
workspace/cache/link指向/etc/shadow或其它租户目录;若只校验「路径字符串落在根下」而不解析最终目标,读工具会越权。 - 硬链接与已打开 FD:同一 inode 可有多条目录项;仅靠路径前缀无法表达「数据是否属于本工作区」。
- TOCTOU:
realpath判定安全后、open之前,攻击者(或并行 tool 调用)把中间组件换成 symlink。 - 大小写与 Unicode 归一化:在默认不区分大小写的卷(部分 macOS/Windows 挂载)上,
Secret.env与secret.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. 将模型路径解析为「候选绝对路径」再做边界判断
推荐算法(只读与写入共用):
- 拒绝空路径、NUL 字节、以及(在 POSIX Worker 上)意外的盘符形式(若业务不支持)。
- 若模型给相对路径:
candidate = root / user_path;若给绝对路径:直接作为candidate(随后仍用物理路径判断是否落在根下)。 - 规范化:对已存在路径用
realpath/Path.resolve(strict=True);对尚不存在的写路径,规范化父目录并拼接最终文件名(见下节)。 - 边界判断使用路径分量前缀,而不是字符串
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、/workspace2 在 startswith("/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;实务上常见两层:
- 快速路径:
resolve+relative_to+open(适合受信工作区、无本地攻击者)。 - 加固路径:用
os.open与dir_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 安全实践)。
参考来源
- realpath(3) — Linux manual page — 解析后规范绝对路径与 symlink 跟随行为
- open(2) — Linux manual page —
O_NOFOLLOW、O_DIRECTORY、O_EXCL与openat语义 - path_resolution(7) — Linux manual page — Linux 路径解析、
./..与挂载点 - POSIX.1 — realpath — 可移植规范化接口
- Python 3 — pathlib —
Path.resolve、relative_to与严格解析 - Node.js — fs.realpath / path — 异步 / 同步真实路径解析
- OWASP — Path Traversal — 目录遍历攻击模式与防御概述
- Model Context Protocol — Roots — 工作区根由客户端声明的模型
- Model Context Protocol — Security Best Practices — 与不可信输入、边界相关的部署建议
- OWASP Gen AI — LLM06:2025 Excessive Agency — 过度代理权限(含文件系统工具)的风险框架
- Landlock — Linux kernel documentation — 非特权进程路径级访问控制(OS 层补充)
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。