给 Agent 一个工作区,然后规定“只能读写这里面”,听起来像一道很清楚的安全边界。
最常见的实现也很直观:先把目标路径解析成绝对路径,确认它仍在工作区根目录下面;如果路径里有符号链接,再用 realpath 看一眼它真正指向哪里。检查通过以后,才允许 Agent 去读、写、删除。
问题是,文件系统并不会因为你刚刚检查过一次,就停下来等你。
如果工作区里的另一个进程能在“检查通过”和“真正打开文件”之间,把某一级目录替换成符号链接,那么两次操作看到的可能已经不是同一棵目录树。授权时它还在工作区里,真正写入时却可能已经指向工作区外。
这类问题有一个老名字:TOCTOU,time-of-check to time-of-use,也就是“检查时”和“使用时”之间出现了竞态。
它最近重新出现在 Agent 工程里,不是因为文件系统突然变了,而是因为 Agent 正在获得越来越真实的执行能力:它会启动进程、运行构建脚本、修改目录,同时 Harness 还要保证这些动作没有越过用户给出的工作区边界。
2026 年 9 月 9 日,OpenAI Agents SDK v0.22.2 合入了一项很具体的修复:prevent UnixLocal file API symlink races。对应 PR 明确描述了风险——如果 workspace process 在路径验证之后、文件操作之前把已经验证的路径替换成 symlink,后续操作可能触达 workspace 或显式授权范围之外的位置。
原始资料:
这不是一个“记得调用 realpath”就结束的问题。它更像是在提醒 Agent Runtime:路径边界不是一个字符串判断,而是一条从授权持续到文件句柄建立完成的执行边界。
先分清两种完全不同的越界#
假设工作区是:
/workspaceAgent 想写:
/workspace/output/result.txt第一种问题很好理解:输入本身就带着 ../,或者某个已经存在的 symlink 直接指向 /etc、用户主目录之类的位置。
这类问题可以在执行前发现:
输入路径
→ 规范化
→ resolve / realpath
→ 判断真实目标是否仍在 workspace如果真实目标已经在工作区外,拒绝即可。
第二种问题更麻烦。
检查时:
/workspace/output → 普通目录检查通过。
随后,在真正写文件之前,另一个 workspace process 做了替换:
/workspace/output → /somewhere/outside然后原来的代码继续执行:
write("/workspace/output/result.txt")路径字符串一点没变,之前的 realpath 也确实没有算错。错的是我们默认了:检查过的路径关系会一直保持到操作发生。
这就是 TOCTOU 与普通路径穿越最大的区别。
OpenAI 这次不是“再检查一次”#
OpenAI 在 PR #4931 里的处理值得看实现,而不只是看 release note。
新的 UnixLocal 文件操作使用 descriptor-relative filesystem operations:从已经打开的目录文件描述符开始,逐级打开下一层目录和目标文件,而不是先得到一个路径字符串,再在稍后的时刻重新从文件系统根部解析这条路径。
关键操作还使用了 O_NOFOLLOW。它告诉操作系统:这里不接受一个刚刚被替换出来的符号链接。
简化理解,大致从:
先验证 /workspace/a/b
...
稍后按字符串再次打开 /workspace/a/b/file变成:
打开并持有可信目录 fd
→ 相对这个 fd 打开下一层目录,禁止跟随替换 symlink
→ 再相对新的 fd 打开目标
→ 完成读写 / 删除PR 里对递归删除也用了同一种思路:基于目录描述符继续向下操作,而不是每一层重新相信一条可变的路径字符串。
这条变化最重要的地方不是 Python API 选型,而是安全边界发生了位置变化:
授权不再只发生在“算出一个安全路径”的那一刻,而是尽量被带进真正的文件系统操作里。
Mira 为什么正好踩在这条线上#
UIChat Mira 当前公开实现已经不是最简单的 path.startsWith(workspace)。
Mira 的工具 Harness 会把读写、终端等能力标记为 workspaceBound。在写文件路径上,当前 resolveWorkspaceWritePath 会先做词法边界检查,然后寻找最近已经存在的祖先目录,再用 fs.realpathSync.native() 得到它的真实路径,确认这个真实祖先仍然位于 workspace 的真实根目录之下。
源码可以直接核对:
这套实现已经能挡住一大类静态 symlink escape。例如目标目录在检查时就已经是一个通向工作区外的符号链接,realpath 后的祖先路径会暴露这一点。
但继续往执行链看,会看到另一层事实。
resolveWorkspaceWritePath() 完成检查并返回字符串路径之后,Edit Runtime 还会做内容准备、dry-run 判断、创建父目录,最后再调用:
fs.writeFileSync(targetPath, ...)也就是说,边界验证和真正打开文件仍然是两个独立的文件系统时刻。
如果一个具有足够文件系统权限的并发 workspace process 能恰好在二者之间替换相关目录节点,就会进入和 OpenAI 这次修复同一类的竞态窗口。
这里必须把“事实”和“推断”分开。
已经验证的事实是:Mira 当前代码确实先解析 / realpath 检查已有祖先,之后再以路径字符串进行 Node.js 文件操作;代码中没有使用 descriptor-relative traversal 或类似 O_NOFOLLOW 的句柄级约束。
工程推断则是:这形成了典型的 validation-to-use 时间窗口。 我们这次没有声称已经在 Mira 上复现出一个可利用漏洞,也没有测试所有平台、工具和权限组合下是否都能稳定触发。因此,它应该被视为一个需要验证和加固的边界,而不是一份虚构的漏洞公告。
这一区分很重要。安全工程最怕两种夸张:一种是“我们用了 realpath,所以肯定安全”;另一种是“看到同类上游补丁,就宣布自己的系统已经被攻破”。两者都比代码本身跑得快。
Approval 也解决不了这件事#
Mira 现在对写文件、终端这类高风险工具有明确的 Approval。具体 invocation 还会绑定参数哈希,避免用户批准的是 A,真正执行时悄悄变成 A'。
这很重要,但它解决的是另一层问题。
假设用户批准了:
写入 workspace/output/result.txt从 Structured Action 的角度看,这个参数从批准到执行完全没有变化。
TOCTOU 发生时,变化的是操作系统在这个路径下面看到的对象关系。
因此:
Approval integrity
≠
Filesystem object identity批准机制可以证明“用户允许了这份 invocation”,却不能证明“执行瞬间的 output 目录仍然是批准时的那个目录”。
这也是为什么 Agent 的控制面不能只盯着模型和工具参数。越往真实世界走,权限最终都会落到某种资源对象上:文件、目录、进程、浏览器页面、远程设备、数据库记录。对象本身如果会在检查与执行之间变化,单纯冻结 JSON 参数并不够。
Parent / Child 会把窗口放大#
这个问题放进多 Agent 和远程执行以后更有意思。
Mira 当前把 Parent 保留为全局任务、Approval 与 checkpoint 的所有者,Child 负责局部工作包;实际工具执行仍然经过 Harness。这个边界可以防止 Child 因为被委派就自动获得无限权限。
但如果 Child 启动了构建、测试或其他长运行进程,而 Parent 随后批准另一个文件操作,工作区此时已经不是静态背景,而是一个正在被多个执行者修改的共享状态。
手机远程调用桌面能力也是一样。用户从手机发出意图,Desktop Harness 真正落地文件操作;两端之间的传输、等待审批、checkpoint 恢复都会拉长“决定要做什么”和“资源实际被使用”之间的时间。
于是一个过去在单进程脚本里很窄的竞态窗口,在 Agent 系统里可能天然变宽。
这不是要求所有 Agent 产品立刻造一个文件系统沙箱。它只是说明:并发执行越强,workspace boundary 越不能只当作输入校验。
我们真正学到的不是 O_NOFOLLOW#
看到 OpenAI 的补丁,很容易直接得到一句工程 TODO:Mira 也去找 Node.js 里对应的 O_NOFOLLOW。
这个结论太快。
不同平台、Windows / Unix、Node.js 文件 API、现有桌面权限模型、未来 disposable workspace 或独立 sandbox,都可能要求不同方案。descriptor-relative traversal 在 Unix 上很漂亮,不代表它能原样成为 Mira 的跨平台答案。
真正应该先吸收的是更上层的设计原则:
第一,Workspace Policy 与 Workspace I/O 不能永久分家。
如果 Policy 只负责在执行前判断路径,而真正文件 I/O 完全不知道这条授权边界,那么两层之间天然存在状态漂移空间。
第二,路径字符串不是资源身份。
/workspace/a.txt 是一个名称。真正被打开的是文件系统在某个时刻通过一组目录节点解析出来的对象。高风险操作越依赖真实资源,就越需要考虑“批准的名称”和“执行的对象”是否仍是一回事。
第三,验证必须覆盖竞态,而不是只覆盖坏路径。
常规测试很容易写:../secret 应该失败,指向外部的 symlink 应该失败。TOCTOU regression test 则必须让另一个进程或线程在验证与使用之间做替换,证明系统面对变化的文件系统仍然守住边界。
这也是 OpenAI 这次补丁真正有价值的地方:它没有把安全建立在“第二次检查大概来得及”上,而是尽量让内核在逐级打开资源时拒绝新的 symlink 替换。
还有哪些事情没有答案#
对 Mira 来说,这篇文章不是补丁说明,更不是宣布下一版一定采用哪一种文件 API。
接下来真正需要验证的至少有四件事:
- 当前
read、edit、递归目录操作和 terminalcwd分别有哪些 symlink / race 行为,不能拿写文件的一条链路代替全部工具; - 在 Mira 支持的 Windows、macOS、Linux 环境里,怎样定义一致又可实现的 workspace object boundary;
- 如果未来把高风险执行放进 disposable sandbox,哪些边界应该由 Sandbox 本身承担,哪些仍属于 Harness;
- Parent / Child、远程 Mobile → Desktop 执行和长任务恢复以后,workspace snapshot 或资源 identity 是否需要成为 checkpoint 的一部分。
这些问题现在都没有必要假装已经解决。
但有一件事已经足够清楚:
“路径检查通过”只能证明检查发生的那个瞬间;真正可靠的 Agent 工作区边界,必须一直延伸到资源被实际打开和使用。
Agent 越能自己运行进程、修改工程、并行施工,这种看起来很底层的文件系统细节,就越不像“安全团队以后再补”的边角料。它已经是 Runtime 的一部分。
