引言
以前看 Agent 文件写入,通常先检查一件事:目标路径有没有留在 workspace 里。Claude Code 公开的 CVE-2026-103435 让我开始重新看这个问题。公告描述了检查与写入之间的时间差:权限检查通过时目标还在项目目录内;共享工作区中的另一个进程替换目录项后,Agent 再按原路径写入,文件就可能落到项目目录之外。Anthropic 的公告
我想弄清楚的是,检查通过时程序确认的究竟是什么。如果它确认的只是一个路径字符串,那么等到真正打开文件时,内核解析到的还会是同一个对象吗?即使函数最后返回拒绝,拒绝之前有没有可能已经改动磁盘?沿着这两个问题,从 Claude Code 公告中的时序开始,再去看 OpenClaw 和 OpenShell 的真实写入与同步函数。
一、路径检查通过后,目标仍可能变化
1.1 从 Agent 工具到文件系统
Agent 发起一次 write_file(path, content),宿主侧还要解析参数、检查权限、打开或创建文件,再把内容提交到磁盘。模型给出目标路径和数据,最终调用文件系统接口的是持有宿主权限的进程。
权限检查处理的是某一时刻的路径;写入操作针对的是最后实际打开的文件对象。如果实现只把路径字符串传给后续的 open()、rename() 或同步函数,文件系统就会再次查找目录项。同一字符串因此可能先后指向不同对象。
这里讨论的是本地文件系统中的共享目录项竞态。攻击者需要能替换相关目录项,并赶在 Agent 后续使用路径前完成操作;宿主进程则需要有权访问工作区外目标。攻击者借用的是宿主已有的文件权限。若这两个条件不成立,越界链就无法完成。
1.2 路径名、目录项与 inode
workspace/draft.txt 是一次查找所用的路径名,不是文件本身。文件系统会从起点逐级查找目录项:workspace 目录项指向一个目录对象,目录中的 draft.txt 再指向具体文件对象。对本文讨论的本地文件系统,可以用 inode 标识文件对象;路径名描述“怎么找到它”,inode 描述“这次找到的是谁”。
符号链接本身保存一段路径,解析器遇到它时会继续查找链接目标。若相关目录项在两次操作之间被替换,同一条路径字符串下一次解析时就可能指向另一个对象。硬链接则会新建一个指向同一 inode 的目录项,不会把一个路径重定向到另一个 inode;但通过任一硬链接修改文件内容,其他名字看到的也是同一份内容。前者改变路径解析结果,后者让多个名称共享同一个文件对象,两类风险不能混为一谈。
文件描述符则绑定到已经打开的对象。打开后,即使原路径名被重命名或替换,已有描述符仍引用原先打开的文件;程序若随后再次调用 open(path)、按路径执行 rename(),或让同步函数重新读取 path,仍会进行新的路径解析。也就是说,FD 可以固定一次打开的对象,但只有在安全地完成打开之后才有这个作用;如果第一次打开已经落到越界目标,FD 只会稳定地指向这个目标。
机制的初始目录如下:

在这个最小目录里,workspace/draft.txt 初始解析到工作区内的 inode A,outside/canary.txt 是工作区外用于观察状态变化的 inode B。若 workspace/draft.txt 的目录项被替换成指向 outside/canary.txt 的符号链接,这串路径字符可以保持不变,下一次查找却会走到 inode B。父目录重绑定也是同样的道理:后续查找会从被替换的目录项继续解析。最终要靠 Canary 读回确认受影响的对象,不能只根据函数返回值判断有没有越界。
1.3 打开与校验的先后顺序决定副作用
路径检查通过,不代表后续操作仍会作用于当时检查的对象。检查之后如果目录项被替换,程序再次按路径打开或提交文件,就可能走到另一个目标;如果 open() 本身带有截断或创建标志,副作用还可能早于最终校验。

真正的问题在于,同一条路径会被多次解析:第一次用于检查,之后又用于打开、提交或同步读取。目录项只要在两次解析之间发生变化,后一次操作就可能落到另一个对象;带有 O_TRUNC 或 O_CREAT 时,文件状态甚至会在拒绝返回前先改变。三个案例分别把这个窗口放在写入、打开和同步阶段。下面先从 Claude Code 的源码看路径为什么会被重新解析,再把相同的时序落到 OpenClaw 和 OpenShell 的实际文件状态上。
二、Claude Code:检查通过后,写入目标为何还会变化
2.1 公开问题与触发条件
Anthropic 披露的 CVE-2026-103435 描述了一次发生在正常文件编辑中的越界写入:Claude Code 检查目标路径时,文件还位于项目目录;真正写入时,程序再次按路径名解析目标。共享工作区里的另一个进程可以在这段间隙替换目录项,让原路径转而指向项目外的文件。Anthropic 公告将受影响版本列为 < 2.1.129,并在 2.1.129 修复。
要让这条链成立,攻击者必须能修改共享工作区中的目录项,并在文件写入前完成替换;宿主进程也必须有权限访问项目外的目标。

工具参数始终是 workspace/draft.txt,变化的是这串路径对应的对象。接下来沿源码追踪权限检查结果是否被写入阶段继续使用。
2.2 从路径校验到文件写入的源码调用链
沿着 Claude Code 2.1.87 的源码快照追调用链。这里的源码只用来说明权限检查结果如何传到落盘函数,不把它写成 Claude Code CLI 的运行结果;问题背景和受影响版本仍以 Anthropic 公告为准。
FileWriteTool.checkPermissions() 将工具输入交给 checkWritePermissionForTool()(FileWriteTool.ts,L135–L142)。该函数通过 tool.getPath(input) 取出目标;如果调用方没有提供预先计算的路径集合,才调用 getPathsForPermissionCheck() 生成待检查路径。
源码位置:filesystem.ts,L1205–L1375。

这里不是简单地检查路径里有没有 ../。getPathsForPermissionCheck() 会把原始路径、符号链接链上的目标和最终解析路径放进待检查集合;目标尚不存在时,它会向上找到最深的现存路径,解析其中的符号链接,再把尚未创建的路径部分补回候选集合。具体实现位于 fsOperations.ts,L288–L381。之后,pathInAllowedWorkingPath() 再把这些路径与当前工作目录及附加工作目录比较。
源码还要求 precomputedPathsToCheck 必须由同一个工具输入派生,避免把旧路径集合用于另一个请求;但这仍只是路径集合,不会固定文件对象。
检查函数返回的是 PermissionDecision;它没有打开目标,也没有把一个已打开的文件描述符交给写入阶段。权限检查通过后,FileWriteTool.call() 又从工具参数里的 file_path 生成 fullFilePath,再创建父目录、读取文件状态,最后把这个路径传给 writeTextContent()。
源码位置:FileWriteTool.ts,L223–L305。

两个阶段之间传递下去的仍是路径名。写入前比较修改时间和内容,可以避免覆盖刚完成的编辑,却不能证明内核随后按 fullFilePath 打开的仍是检查时那个 inode。从生成路径到调用写入函数之间还有目录处理、诊断通知和文件历史等操作;共享工作区里的其他进程不受这些内部并发控制约束。
2.3 原子写入阶段再次解析 targetPath
writeTextContent() 最后调用 writeFileSyncAndFlush_DEPRECATED()。这个函数采用临时文件再重命名的方式提交内容;提交前,它会重新处理传入的 filePath。
源码位置:file.ts,L362–L437。

为了保留符号链接语义,函数在写入阶段调用 readlinkSync()。如果 filePath 此时是符号链接,它会读取当前链接内容,更新 targetPath,再用这个目标构造临时文件名。决定写入位置的,是写入阶段重新读取到的链接目标。
临时文件写好后,代码再用 renameSync() 把它提交到 targetPath:

检查时 workspace/draft.txt 指向工作区文件;若检查后被换成指向 outside/canary.txt 的符号链接,写入函数读取到的就是新目标。临时文件加 renameSync() 能避免提交半截内容,却不能保证目标仍在授权目录内;被替换的是父目录时,临时文件创建和重命名也会重新解析路径。
Claude Code 这部分的关键不在于有没有临时文件,而在于权限检查没有把目标变成一个固定的文件对象:FileWriteTool.call() 重新从 file_path 生成路径,原子写入又用 readlinkSync() 和 renameSync() 重新解析。目录项如果在中间被替换,检查时看到的对象就不再约束最终写入。
接下来换到 OpenClaw 的真实函数,直接看 fs.open() 早于后置校验时,O_TRUNC 和 O_CREAT 会不会先改变磁盘状态。
三、OpenClaw:越界拒绝前已经发生的文件副作用
3.1 writeFileWithinRoot() 的旧版调用顺序
公开问题 GHSA-x82f-27x3-q89c 给出了验证入口。我对照受影响版本 v2026.2.26 和修复版本 v2026.3.1,分别导入对应标签中的真实函数,只替换加载的源码版本。观察重点是函数返回拒绝时 outside 文件的状态,而不只是异常类型。
旧标签 v2026.2.26/src/infra/fs-safe.ts 的写入标志是:
const OPEN_WRITE_FLAGS =
fsConstants.O_WRONLY |
fsConstants.O_CREAT |
fsConstants.O_TRUNC |
(SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);写入函数的关键顺序可以抽象成:
rootReal = realpath(rootDir)
resolved = path.resolve(rootReal, relativePath)
assertNoPathAliasEscape(resolved, rootReal)
resolvedRealPath = realpath(resolved) # 已有目标时
ioPath = resolvedRealPath 或 resolved # 缺失目标时
fs.open(ioPath, O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW)
handle.stat() + lstat(ioPath) + inode 比较
realpath(ioPath) + 根目录检查
handle.writeFile(data)这里最容易漏掉的是时间顺序:realpath() 只记录检查那一刻的结果,后面的 fs.open(ioPath, flags) 仍然拿字符串重新查目录项。检查阶段选中的对象没有通过 FD 传给打开阶段。实验只替换父目录 inside,最后一段 target.txt 完全不动,但同一个路径已经可以指到 outside。
旧版的动作顺序是:先用 realpath 和根目录检查路径,随后进入 fs.open()。钩子在真正打开前把 inside 换成指向 outside 的目录;放行后,O_TRUNC 先作用于 outside,函数才用句柄身份和根目录再做复核。

实际执行时,路径检查已经完成,inside 才被替换;fs.open() 随后重新解析 ioPath,O_TRUNC 先作用于 outside,句柄身份和根目录检查最后才返回 invalid-path。

第42–46行的 OPEN_WRITE_FLAGS 含 O_CREAT 和 O_TRUNC,所以打开动作可以创建文件,也可以清空已有文件。平台支持时还会加入 O_NOFOLLOW;它约束路径最后一段,不会阻止系统解析被替换的父目录 inside -> outside。

第225行先调用 fs.open(),第237行才通过 handle.stat()、lstat(ioPath) 比较句柄和当前路径对应的对象。身份检查能够发现打开结果与路径状态不符,却不能撤销 open() 已经执行的截断或创建。

第256–263行才检查 realPath 是否仍在根目录内,检查通过后才执行 handle.writeFile(data)。因此旧版的拒绝发生在写入数据之前,但晚于 O_TRUNC 和 O_CREAT。已有文件用例中的 NEW 不会写入 outside;之后看到的空文件来自 open() 的截断。源码位置:OpenClaw v2026.2.26 src/infra/fs-safe.ts,L39–L261。
3.2 已有文件:O_TRUNC 早于拒绝改变了 outside
实验直接加载两个标签中的 fs-safe.ts,调用真实的 writeFileWithinRoot()。两个版本、被测文件哈希和运行环境已经固定;每个用例使用独立临时目录,先记录初态,函数返回后再独立读回磁盘。
目录结构是 workspace/inside/target.txt 和 outside/target.txt。触发点设在真实函数调用 fs.open() 时:钩子先把 inside 改名为 old-inside,再创建 inside -> outside,然后调用原始 fs.open()。因此,受控替换发生在路径检查之后、实际打开之前;没有修改被测函数,也没有模拟 open() 的返回值。已有文件用例预置 4096 个 X,缺失文件用例确保两个目标都不存在。
以“已有文件、旧版”为例,先确认初始状态:

初始状态中,工作区目标内容为 INSIDE,outside Canary 为 4096 字节。
接着调用真实函数。拦截点只放在 fs.open() 前:先将 inside 改名为 old-inside,再建立 inside -> outside,最后把原路径和标志交给系统打开。终端输出里的 injection、openFlags、result 和 outside 分别对应替换是否命中、实际打开标志、函数返回和独立磁盘读回。
这个钩子把竞态固定在“路径校验后、系统打开前”,用于比较两个版本在相同目录项变化下的结果;它不测独立攻击进程赢得竞态的概率。
函数调用后的终端记录同时给出返回值、打开标志和 outside 状态:

父目录替换在打开前命中,函数虽返回 invalid-path,outside 文件已被截断。
再看函数返回后的独立磁盘读回:

独立读回确认 outside Canary 已被截断,移走的原工作区文件仍然存在。
函数返回了 invalid-path,outside Canary 却从 4096 字节变成 0。openFlags=[1793] 含 O_TRUNC;源码顺序表明清空发生在 fs.open(),异常则早于 handle.writeFile(data),因此输入 NEW 没有写进 Canary。
磁盘读回还确认了被操作的对象:workspace/inside 已指向 outside,而移到 old-inside 的原工作区文件仍是 INSIDE。发生变化的是 outside Canary,原工作区目标没有被覆盖。
修复版使用另一份目录,保持同样的工作区内容、Canary 大小和目录替换动作:

修复版从新的实验目录开始,工作区目标和 outside Canary 与上一轮保持相同。
修复版的终端记录给出函数返回与文件读回结果:

同一目录替换在修复版中也命中,但函数在修改 outside 文件前返回拒绝。

独立读回确认目录项已经替换,但 outside Canary 仍保持 4096 字节。
这里 openFlags=[257] 不含 O_TRUNC。injected=true、inside -> outside 和被移走的 INSIDE 文件确认替换已经命中;Canary 保持 4096 字节,是因为修复版先打开,再检查实际对象,越界时尚未执行 truncate(0) 或写入。
这不表示修复版从未打开 outside:同一次替换仍会影响 open() 的路径解析,函数可能先拿到 outside 的写句柄。实验只证明它在拒绝前没有改动文件内容。修复版的源码顺序见下方两处:先以不含创建、截断标志的方式打开并核对身份,再在边界检查通过后截断和写入;缺失目标则走独占创建分支。
修复版源码位置:OpenClaw v2026.3.1 src/infra/fs-safe.ts,L328–L394。


第二处源码显示,只有句柄身份、当前路径和根目录检查通过,才会执行 truncate 与 writeFile。这与 4096 → 4096 的读回结果一致:源码解释控制流,终端确认替换命中后的实际文件状态。
3.3 缺失目标:拒绝前先被创建
这组用例开始时,工作区内的 target.txt 不存在,outside 目录里也没有 Canary。旧版初态如下:

执行前工作区目标和 outside Canary 都不存在。
目录中只有 . 和 ..,没有 target.txt。旧版调用结果如下:

父目录替换命中,旧版打开目标时使用了带 O_CREAT 的标志。
再看函数返回后的独立磁盘读回:

独立读回确认 outside 新增了一个 0 字节文件。
执行前 outside 中没有目标,函数拒绝后却出现了一个 0 字节文件。这里没有旧内容可供截断,副作用来自 O_CREAT:open() 沿被替换的父目录在 outside 创建了文件。后置检查抛出异常,并不会撤销创建;终端读回记录的是函数返回后的状态,没有并发观察者监测创建到清理之间的短暂窗口。
修复版使用另一份空目录,目标文件和 Canary 同样都不存在:

修复版从独立的空目录开始,工作区目标和 outside 目标都不存在。
修复版调用结果如下:

第一次打开返回 ENOENT,修复版随后进入独占创建分支并在边界检查失败后清理。

独立读回显示 outside 最终没有留下目标文件。
修复版先以 [257] 打开目标;因为目标不存在,调用返回 ENOENT,随后以 [2817](O_CREAT|O_EXCL)独占创建。后置根目录检查发现越界后,函数清理了这次创建的文件。于是终端最终读到 ENOENT:这证明没有遗留文件,不证明文件从未短暂出现。
日志中的十进制标志是 macOS arm64 / Node 环境的 Darwin 常量:
日志值 | 标志组合 | 本用例中的含义 |
| O_WRONLY、O_CREAT、O_TRUNC、O_NOFOLLOW | 旧版打开时可创建并截断 |
| O_WRONLY、O_NOFOLLOW | 修复版先尝试打开已有文件,不创建、不截断 |
| O_WRONLY、O_CREAT、O_EXCL、O_NOFOLLOW | 文件缺失时独占创建 |
3.4 拒绝返回与磁盘状态
把四次调用放在一起,差别就很清楚:
版本 | outside 原本有文件 | outside 原本没有文件 |
| 返回拒绝,Canary | 返回拒绝,但留下 0 字节文件 |
| 返回拒绝,Canary 保持 4096 | 返回拒绝,最终没有遗留文件 |
旧版的问题不是“拒绝失效”,而是拒绝来得太晚:已有文件先被 O_TRUNC 清空,缺失文件先被 O_CREAT 建出来。修复版把破坏性写入推迟到对象身份和根目录检查之后;缺失文件用例里的 [257,2817] 又说明它确实走过“先尝试打开、再独占创建”的分支,只是越界后清理了产物。
四次调用都走两个标签中的真实 writeFileWithinRoot(),目录替换由钩子固定在路径检查和 fs.open() 之间。它们能确认越界截断、越界创建和修复后的最终状态,但不等于任意内容写入或代码执行,也没有测量独立攻击进程赢得竞态的概率;
四、OpenShell:临时文件也没有把落点固定住
上一章把副作用定位在第一次 open():旧版先截断或创建,之后才拒绝。OpenShell 把写入拆成临时文件、rename() 和同步三个阶段,问题就变成了另一种形式——检查通过后,这几个路径操作是否仍然落在原来的父目录。
4.1 bridge 的宿主写入路径
上一章是 open() 太早,这一章看一个更容易让人放松警惕的版本:先写临时文件,再用 rename() 提交,最后才同步。临时文件名、提交目标仍然是字符串路径;父目录检查后被换掉时,这两个操作会不会一起走到 outside?
这里的 OpenShell 是 OpenClaw 仓库里的扩展。Agent 传入 /workspace/alias/output.txt 后,bridge 把它映射成宿主机 hostPath,检查 mountHostRoot,写完本地文件,再调用 syncLocalPathToRemote()。一次写入实际分成“宿主落盘”和“同步读取”两段,本章先验证前者,第五章再验证后者。
修复提交 7be82d4 给出的关键变化不是换了文件名,而是把根目录约束放进了实际写入函数。旧流程中的临时文件和目标文件仍相对于可变父目录构造,父目录一旦被替换,后续路径操作就会重新跟随它。
实验链路固定在一个目标上:容器路径 /workspace/alias/output.txt 映射到宿主机的 workspace/alias/output.txt,workspace 是允许写入的根目录,outside 位于根目录之外。每个版本都从同一个普通目录项开始,bridge 完成路径检查后,注入动作执行两次目录项操作:
workspace/alias → workspace/old-alias
workspace/alias → outside替换完成后,命令仍把原来的 hostPath 传给发行包函数。旧版会继续执行临时文件写入、rename() 和同步;修复版会在 writeFileWithinRoot() 中拒绝重绑定后的路径。这样两种版本使用同一输入、同一替换动作,差异只来自写入约束所在的位置。
实验只改变一个条件:outside/output.txt 是否预先存在。无论哪种用例,bridge 都先把容器路径映射到宿主路径并完成检查,目录项替换发生在检查之后;旧版继续执行临时文件写入、rename() 和同步,修复版在受约束的写入函数内返回。
“旧版 + 已有文件”这次运行把几个关键动作串在了一起:bridge 先把容器路径映射到宿主路径,检查通过后,alias 被换成指向 outside 的目录;临时文件写入、rename() 和同步仍沿原来的字符串路径继续执行。


运行时先完成路径映射和根目录检查,随后发生父目录重绑定;临时文件写入和提交动作沿新目录继续,最后才进入同步 stub。
4.2 临时文件和 rename() 仍按路径找
[email protected] 发行包中,桥接写入分支的核心顺序是:
const parentDir = path.dirname(hostPath);
const tempPath = path.join(
parentDir,
`.openclaw-openshell-write-${path.basename(hostPath)}-${process.pid}-${Date.now()}`,
);
await fs.writeFile(tempPath, buffer);
await fs.rename(tempPath, hostPath);
await this.backend.syncLocalPathToRemote(hostPath, target.containerPath);在同一文件系统中,rename() 只保证名称切换是原子的,不替应用判断新名称是否还在授权根目录里。POSIX rename() 规范和父目录重绑定对照实验都给出了同一个结果:按可变路径提交会在 outside 产生文件,改用可信父目录 FD 后则不会。这个对照只解释 rename() 的语义,发行包是否真的沿着这条路径,要看 bridge 的实际调用结果。
在同一父目录重绑定对照里,按可变路径拼接临时文件会在 outside 产生文件;改用已经打开的可信父目录 FD,临时文件和提交都会留在 inside。它只说明“临时文件 + rename”模型会受父目录重绑定影响,不替代 OpenShell 发行包的真实调用。
这里的 parentDir、tempPath 和 hostPath 仍然是路径名。父目录别名被替换后,writeFile(tempPath) 和 rename(tempPath, hostPath) 会重新解析这些名字;两步可以都在 outside 完成,rename() 仍然是原子的,只是原子地提交到了错误目录。

旧版 writeFile() 先在 parentDir 下拼出临时文件名,再把临时文件按 hostPath 重命名,最后才进入同步分支。红框中的三次路径操作都接收字符串路径,没有把已经检查过的父目录绑定成文件描述符。

修复版把同一段逻辑改成 writeFileWithinRoot() 加同步调用。红框显示同步位于受根目录约束的写入 Promise 之后;写入阶段拒绝时,同步函数不会被执行。
两处源码把差异定位在同一个位置:旧版把临时路径和提交路径交给基于路径名的文件操作;修复版先等待 writeFileWithinRoot() 完成,再调用同步函数。outside 的最终文件状态和同步 stub 的调用次数正好能把这两个分支区分开。
4.3 旧版写入结果
旧版在路径检查后按 parentDir 拼出临时文件名。终端记录中的 injected=true 表示目录项替换确实命中了 bridge 的文件操作;随后函数没有拒绝,而是继续完成临时文件写入、rename() 和同步。
outside 原本已经存在文件时:

初态中的 OUTSIDE_CANARY 是 14 字节。替换命中后,旧版仍返回 rejected=false,outside 变成 15 字节的 OUTSIDE_VERSION,syncCount=1。变化发生在 rename(tempPath, hostPath),它把临时文件提交到了工作区外的同名路径。
再看 outside 原本没有目标文件的用例:再看 outside 原本没有目标文件的用例:


这里初态的 outside.exists=false,终态变成 outside.exists=true、bytes=15。同一条旧版调用链在 outside 没有目标时表现为越界创建。injected=true 证明替换命中了文件操作,syncCount=1 证明本地同步 stub 被调用;两者都不等于远端 OpenShell 已经收到文件。
函数返回后的独立读回显示,alias 已经是指向 outside 的符号链接,原目录项移到了 old-alias,工作区内没有留下写入对象;终端记录中的 final.alias 和 final.local 字段对应这一状态。
4.4 修复版的拒绝位置
修复版沿用同一组输入和目录项替换动作,只把发行包换成 2026.4.22。outside 已有文件时:

初态中的 OUTSIDE_CANARY 在终态仍然保留,rejected=true,syncCount=0。修复版没有进入 rename(),拒绝后也没有调用同步。
再看 outside 缺失文件的用例:

这里 injected=true 说明替换动作仍然命中了注入点;rejected=true、outside.exists=false 和 syncCount=0 则说明真正的宿主写入还没有开始,outside 没有新文件,同步分支也没有执行。
final.alias.isSymbolicLink=true 只说明实验注入完成,不能当成写入成功的证据;是否越界要看 outside 终态和 syncCount。
四组结果中,injected 都为 true,说明目录项替换确实命中了 bridge 的文件操作;真正有差异的是函数返回、outside 文件状态和同步调用次数。
发行包 | outside 初态 | 函数返回 | outside 终态 |
|
| 已有 | 成功 | 被改为 | 1 |
| 不存在 | 成功 | 创建 | 1 |
| 已有 | 拒绝 | Canary 保持不变(14 字节) | 0 |
| 不存在 | 拒绝 | 仍不存在 | 0 |
这张表把两个判断分开:旧版的越界落点由 outside 的内容变化确认,后续同步是否继续由 syncCount=1 确认;修复版则同时满足“outside 没有变化”和“同步没有调用”。因此修复版的结论不是单纯返回了错误,而是拒绝发生在宿主写入和同步之前。
五、同步链:路径再次解析后的对象变化
第四章把问题停在宿主写入阶段:OpenShell 2026.4.22 能在临时文件和提交动作开始前拒绝父目录重绑定。但一次 Agent 写入并不止这一步。bridge 返回后,后端还要把本地文件同步到 sandbox;如果同步前又按路径检查、等待、上传,前一阶段的修复并不能自动约束后一阶段。
这轮实验把最后的上传边界换成真实 OpenShell CLI 0.1.7。gateway 和 sandbox 都是真实组件,远端工作目录是 /sandbox;正常基线、父目录替换和原位内容修改分别用远端 exec 与 download 读回。
5.1 lstat() 和 sandbox upload 之间还隔着一次路径查找
OpenClaw 2026.4.22 的同步后端先对 localPath 执行 lstat(),处理缺失文件、符号链接和目录;普通文件继续等待远端父目录准备,最后把同一个 localPath 交给 openshell sandbox upload。实现位于 v2026.4.22 backend.ts,L783–L826。下面两张源码截图对应 v2026.4.22 的 backend.ts,分别展示这个函数中执行检查和调用上传的位置。
这段代码里没有把 lstat() 得到的对象或文件描述符传给上传命令。等待远端父目录准备的过程本身又增加了一段时间窗口,上传进程随后按路径名重新打开本地源文件。路径字符串没有变化,不代表它解析到的目录项和文件对象没有变化。
源码中的关键顺序可以压缩成:
const stats = await fs.lstat(localPath).catch(() => null);
// 缺失、符号链接、目录分别处理
await this.runRemoteShellScript({
script: "mkdir -p -- \"$(dirname -- \"$1\")\"",
args: [remotePath],
});
await runOpenShellCli({
args: ["sandbox", "upload", sandboxName, localPath, dirname(remotePath)],
});源码页面中,这三个动作出现在同一个同步函数里:先拿到 lstat 结果,随后准备远端父目录,最后仍把路径字符串交给 sandbox upload。检查结果没有变成 FD,上传阶段仍会重新按路径取源文件。下面两张截图分别对应这个函数的检查位置和上传位置,版本均为 v2026.4.22。


这条链的时间顺序是:
backend.lstat(localPath)
↓
真实远端父目录准备
↓
本地目录项或文件内容发生变化
↓
真实 openshell sandbox upload(localPath)
↓
远端 sandbox 保存上传结果把这几个动作按实际顺序排开后,竞态窗口就不再是一个抽象的“检查之后”:先是 backend 对 localPath 执行 lstat(),随后等待远端父目录准备;可修改目录项的进程在这段时间把 alias 换到 outside,CLI 最后仍按原始路径重新打开并读到 inode B。

这里验证的是 Agent 文件工具触发的单文件同步路径。它不同于后端的 uploadPathToRemote():后者会先把目录内容复制到临时目录,再上传临时目录;syncLocalPathToRemote() 则直接把 localPath 交给 CLI。两条路径的对象关系不能混在一起。
5.2 同步检查与实际读取的对象
真实 CLI 之前,我沿用 [email protected] 的发行包,把 OpenShell CLI 换成本地记录器。这样做不是为了模拟远端,而是先把“backend 检查了谁、消费者实际打开了谁”分开记录。记录器只按收到的路径执行 realpath、stat 和读取,不会把结果写入远端。
由于发行包入口没有直接导出这两个内部函数,实验副本只在 index.js 末尾追加导出语句,函数体没有改动。Node REPL 中逐段调用发行包实现的 bridge、backend 和 syncLocalPathToRemote();远端建目录调用由本地函数替身返回成功,注入发生在 backend 的 lstat() 返回之后,CLI 记录器保存它按路径实际打开的对象。
先在同一个 Node REPL 中实例化发行包里的 backend。backend ready 只表示后续调用已经接到 [email protected] 的后端,还没有进入 bridge 写入路径,文件状态尚未改变。


接着暂时接住 syncLocalPathToRemote(),让它只记录参数并等待后续放行。sync deferred 说明同步入口已经被截获,但此时没有读取本地文件;后面的内容变化只能来自 bridge 的宿主写入,不会被同步消费者提前混入。

在同一运行时中用 sandbox 和 backend 构造真实 bridge。bridge ready 表示后续 writeFile() 会进入发行包实现,而不是测试替身;到这里仍然没有触发目标文件写入。

现在只触发一次 bridge 的本地写入,把同步读取留在延迟状态。local-write-complete 表示宿主目标已经写入 13 字节 INSIDE_MARKER;sync-deferred 仍在,说明 bridge 已经返回但消费者尚未读取,这一步建立了后续 lstat() 对照所需的 workspace 基线。


宿主写入完成后,对当前目标做一次 stat(),记录 dev、ino 和字节数。13 字节的 INSIDE_MARKER 仍位于 workspace;这些字段作为后面判断“backend 检查的是哪个对象”的参照,但不代表 CLI 一定会继续使用同一个 inode。


为确认检查点确实落在发行包的同步后端,再给其中的 fs.lstat() 加观察输出。lstat trace installed 只表示观察点安装完成;之后出现的路径、inode 和内容才来自 backend 实际执行的检查,而不是手工读取旁路结果。

随后安装父目录重绑定钩子,并把它放在远端父目录准备阶段。钩子只执行:
alias → old-alias
alias → outside它不改写目标文件内容;这样一旦后面的消费者读到 outside,变化只能归因于路径重新解析。

恢复真实同步调用后,远端目录准备阶段第一次暂停。此时记录的 realpath、inode 和内容仍属于 workspace,CLI 还没有启动;检查已经完成,消费者尚未打开,替换窗口就在这里形成。

放行暂停点后,钩子才执行父目录替换。第二个暂停点的 aliasIsSymlink=true 说明目录项已经改变,而传给后端的路径字符串没有改变;这正是检查结果与后续路径解析之间的竞态窗口。

再次放行后,backend 把原始 localPath 交给 CLI 记录器。记录器没有收到之前的 inode 或 FD,而是按路径重新打开;sourceRealPath 已变成 outside,inode 与检查阶段不同,内容为 OUTSIDE_SECRET,于是“检查对象”和“消费者对象”直接对应起来。


bridge 先完成宿主写入,backend 在 workspace inode 上执行 lstat();父目录替换后,CLI 再按原始路径读取,最终打开 outside inode。bridgeReturned=true 只说明同步函数没有因为对象变化抛错;检查阶段与消费者阶段的 realpath、inode 和内容发生对应变化,才构成路径重解析的证据。
下表只比较同一用例内的检查对象和消费者对象,inode 数值会因每次临时目录不同而变化。这里的记录来自本地消费者实际打开和读取的结果,不是远端文件终态。
用例 | 后端检查时 | shim 实际打开并读取 | bridge 返回 |
无注入对照 | 工作区 inode,13 B, | 同一 inode、同一内容 | 成功 |
| 工作区 inode,13 B, | outside inode,14 B, | 成功 |
| 工作区 inode,13 B, | 同一 inode,16 B, | 成功 |
5.3 同一 inode 的内容变化
先做一个无注入基线。另建 control 目录,恢复真实同步函数,不安装任何替换钩子。bridge 写入 INSIDE_MARKER 后,CLI 记录器看到的 realpath、inode 和内容都应与 backend 检查结果一致。


这组输出先排除“记录器无论如何都会读 outside”。然后另建 mutate 目录,让同步阶段的钩子只原位改写目标文件,不替换父目录。backend 检查的是 13 字节的 INSIDE_MARKER,CLI 最终读取的是同一个 inode 上的 16 字节 CONTENT_INJECTED。
这一次没有发生路径越界,变化发生在写入完成和同步读取之间。它说明对象身份相同,也不等于消费者拿到的字节一定还是检查时的字节。


输出里的 before、after、sameInode=true 和 CONTENT_INJECTED 是这组对照的关键字段;它和上一节的父目录重绑定用例分别对应“同一 inode 换内容”和“路径换到另一个 inode”。
两组对照把变化分成两类:父目录替换让 CLI 读到另一个 inode,原位改写则让它从同一 inode 读到新字节。backend 传递的是路径名,lstat() 只记录检查时刻的状态,没有替后续读取固定对象或固定内容。若同步仍由 CLI 按路径读取,就要把已验证的文件对象或不可变内容交给消费者,并绑定到获准的远端目标。
本地 shim 到这里就停下。它说明 lstat() 只记录检查时刻的状态,不能替后续读取固定文件对象或固定内容;但它没有启动真实 OpenShell CLI、gateway 或远端 sandbox。下面保留同一注入时序,只替换最后的消费者,观察变化能否越过同步边界。
5.4 真实 CLI:父目录替换后,outside 内容进入远端
真实 CLI 先完成一次无注入的 upload → exec → download,确认 gateway、sandbox 和回读链路正常;
真实 CLI 完整链路:backend.lstat → remote mkdir → alias → outside → real sandbox upload → remote exec/download。
本地目录保持如下结构:
workspace/alias/output.txt INSIDE_MARKER
outside/output.txt OUTSIDE_SECRETbridge 先完成正常本地写入。backend 的 lstat() 返回时,localPath 仍解析到 workspace 文件,记录为 inode A;真实远端父目录准备完成后,注入动作才执行:

为便于叙述,下面把远端目标简写为 /sandbox/alias/output.txt。真实 CLI 输出中的路径带有实验目录前缀,实际显示为 /sandbox/auto-race/alias/output.txt。
workspace/alias → workspace/detached-alias
workspace/alias → outside之后没有改变传给 backend 的 localPath 字符串,真实 CLI 仍接收原来的路径。变化的是这条路径再次解析时经过的父目录项。
第二次暂停时,alias 已经变成指向 outside 的符号链接;同一个 localPath 重新解析后,realpath 和 inode 都发生了变化。

两个 OpenClaw 版本的本地检查和远端读回结果如下。inode 数字只对应该次运行;下表用 A、B 表示两次检查得到的对象身份:
版本 | lstat() 检查时 | 替换后上传前 | 远端 exec / download |
2026.4.21 | inode A,INSIDE_MARKER | inode B,outside/output.txt | OUTSIDE_SECRET |
2026.4.22 | inode A,INSIDE_MARKER | inode B,outside/output.txt | OUTSIDE_SECRET |
这次运行中,2026.4.21 的本地 lstat inode 为 75485747,父目录替换后 outside inode 为 75485748;2026.4.22 的本地 lstat inode 为 75485785。inode 只用于串起该次运行的对象身份,不作为跨机器稳定值。两版远端读回的 SHA-256 都是:
5e1589b1ddbe6cff914a6cca1340469980f1bcfb6383d75381244c8fb3f721a2与本地 outside/output.txt 的摘要一致;download 回本地后仍是同一个摘要。
这里不把“真实 CLI 打开了 inode B”写成 CLI 内部系统调用的直接观测。能够直接确认的是:上传启动前,backend 传入的本地路径已经解析到 outside inode B;真实 CLI 返回成功,远端文件内容又与 outside Canary 的摘要一致。三段结果连在一起,说明 outside 对象的字节已经越过本地同步边界,进入远端 sandbox 的目标路径。
2026.4.21 的真实 upload 返回后,远端 exec 和 download 都读到了 outside Canary,终端读回的远端路径、摘要和文本如下。

2026.4.22 重复同一输入和替换时机,结果仍由真实 CLI 读回,而不是本地 shim 伪造。远端摘要和 OUTSIDE_SECRET 与上一轮一致:

这组结果和第四章的关系很明确:2026.4.22 能挡住宿主临时文件和 rename() 阶段的越界落点,但同步后端仍把可变路径交给上传进程,所以同一个父目录替换可以在更晚的阶段再次生效。
5.5 真实 CLI:同一 inode 上传了新字节
第二组变化不替换父目录,也不创建符号链接。backend 的 lstat() 返回后,直接把目标文件原位改写为 CONTENT_INJECTED;dev 和 inode 保持不变。
检查时:inode A / INSIDE_MARKER / 13 字节
修改后:inode A / CONTENT_INJECTED / 16 字节
上传后:远端 CONTENT_INJECTED2026.4.21 和 2026.4.22 的结果都一样。两版远端收到的 SHA-256 都是:
7dd9b3b298033987cbbc4643f99a5ca124b31a867f2a33be556e2c46e3d2c993这个摘要对应 CONTENT_INJECTED。它说明另一类问题:即使同步后端检查时记录了正确的 inode,后续上传仍可能读取同一个 inode 上已经变化的字节。文件对象身份没有变化,内容一致性仍然没有得到保证。
这次没有换父目录。lstat() 记录的 inode 在修改前后保持不变,但远端和 download 都收到了新内容。实际终端输出如下:

这不是第二个越界路径,而是同一条检查—消费链上的内容竞态。父目录替换回答“上传时找到的是谁”,原位改写回答“找到同一个对象后,上传的还是不是检查时的字节”。
5.6 宿主写入修复没有覆盖同步读取
把两轮结果放在一起,OpenClaw 两个版本在同步阶段没有表现出差异:
阶段 | 2026.4.21 | 2026.4.22 |
宿主 bridge 写入 | 旧版存在临时文件/提交落点问题 | 宿主写入阶段已增加根目录约束 |
backend lstat() 后的父目录替换 | 真实 CLI 上传 outside 内容 | 真实 CLI 上传 outside 内容 |
lstat() 后的原位修改 | 真实 CLI 上传新字节 | 真实 CLI 上传新字节 |
因此,第四章的修复只能说明宿主写入阶段不再沿旧版方式把临时文件提交到 outside,不能说明后续同步消费者已经绑定了同一个文件对象或同一份字节。要覆盖目前暴露的窗口,后端需要在检查和上传之间固定源文件对象或不可变内容,不能把裸路径继续交给另一个进程重新打开。
这一轮真实 CLI 实验的攻击效果也有边界:
远端目标仍是 backend 指定的固定路径,本轮截图中显示为 /sandbox/auto-race/alias/output.txt;
成立前提是攻击者能在 lstat() 之后修改共享目录项或文件内容,宿主进程能读取 outside;
结果证明了远端文件内容被替换或投毒,不等于任意远端路径写入,也不等于代码执行;
真实 gateway、sandbox、upload、exec 和 download 都参与了验证,但没有进一步测试远端应用如何消费这份文件。
六、问题根因与防护方案
前面几章把同一个问题分别落到了写入、提交和同步读取:检查阶段看到的对象,没有被带到后续真正执行的位置。下面不再增加新的产品案例,直接把这个断点拆成根因、防护和回归验证。
6.1 问题根因:检查对象和实际对象不是同一个
从代码层面看,断点表现为:检查时确认的是一个对象,真正动文件或读取文件时,代码又拿路径字符串找了一遍。Claude Code 把路径继续传给写入阶段;OpenClaw 旧版在 open() 里先截断或创建,之后才发现越界;OpenShell 旧版把临时文件、rename() 和同步都交给可变路径。
位置 | 结果 | 修复方向 |
检查后再次 | 重新解析到 outside inode | 打开动作带根目录约束 |
|
| 破坏性操作放到核验之后 |
临时文件到 | 提交落到 outside | 不用未绑定的路径字符串 |
| 消费者读到另一个 inode 或新字节 | 传递已验证对象或内容 |
把三个实现放到同一条路径上,差别就清楚了:检查阶段都先看到工作区 inode A,目录项随后被替换,后续操作仍使用原来的路径字符串。Claude Code 在写入阶段重新打开或提交,OpenClaw 在 open() 阶段先触发截断或创建,OpenShell 则在同步阶段把路径交给消费者再次读取。


三个攻击面的副作用落在不同位置,但共同缺口只有一个:检查结果没有绑定到文件描述符、可信父目录或后续消费对象。
只补一条 realpath() 不够。后面的代码只要再次执行 open(path)、rename(path) 或 CLI 按路径读取,前一次检查就只代表当时那一次解析。
6.2 打开、提交与消费的对象绑定
O_NOFOLLOW 只限制最后一个路径分量,挡不住 inside/ 或 alias/ 这样的父目录被换成 Symlink;第三章的 Canary 截断已经说明了这一点。Apple-open(2)手册和 Linux-open(2)手册都把它限定在最后一个名字。
Linux 的 openat2() 可以从可信根目录 FD 解析整条路径:
struct open_how how = {
.flags = O_WRONLY,
.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};
int fd = syscall(SYS_openat2, rootfd, "draft.txt", &how, sizeof(how));RESOLVE_BENEATH 约束根目录边界;需要完全禁止 Symlink 时,再加 RESOLVE_NO_SYMLINKS。Linux 对照中,叶子和父目录越界在 RESOLVE_BENEATH 下都返回 EXDEV;再加 RESOLVE_NO_SYMLINKS 时,符号链接解析返回 ELOOP,outside Canary 保持不变。这个结果只说明约束放进实际解析动作后能挡住重定向,不代表产品已经使用了 openat2()。
后续提交和同步也不能重新使用裸路径。可信父目录打开后,用相对 FD 的 openat()、renameat() 可以继续指向原目录对象;但 FD 不负责判断这个目录是否仍属于授权根。硬链接同样不能忽略:两个名字可以指向同一 inode,写工作区内的名字仍会改变 outside 硬链接看到的内容。
6.3 测试要读回磁盘,不能只等一个异常
旧版返回 invalid-path 时,Canary 已经从 4096 字节变成 0;缺失文件用例里,拒绝返回后还留下了新文件。因此回归测试不能只断言 rejects.toThrow(),还要在返回后读回 outside 的存在状态、长度和内容;OpenShell 要检查 syncCount,同步用例要比较 backend lstat() 的 inode 与消费者最终打开的 realpath、inode 和内容。
这些结果分别回答“有没有拒绝”“磁盘有没有变化”“同步有没有继续”“消费者最后拿到谁”。父目录替换需要目录项修改权限,原位改写需要目标文件写权限;修复版最终清理干净,也不能据此排除创建到清理之间的短暂窗口。
做完这几组实验,最容易误判的就是把 rejected 当成“什么都没发生”。真正的修复应让 open()、rename() 和同步读取使用已核验的对象或内容;如果只能传路径字符串,就必须在每个重新解析点重新约束,并把磁盘副作用纳入测试。
七、总结
本文通过 Claude Code、OpenClaw 和 OpenShell 的源码分析与对照实验,验证了路径检查与实际文件操作之间的安全边界问题。实验表明,即使最终返回拒绝,越界创建或截断等副作用仍可能已经发生;即使写入阶段完成校验,后续同步读取也可能访问不同的文件对象或内容。
问题的关键不只是路径是否通过检查,而是检查结果能否约束后续真正产生副作用的操作,以及最终被消费的文件对象和内容。 这也是 Agent 文件操作链路中需要持续关注的安全边界。
参考资料
OpenClaw:
writeFileWithinRoot()越界创建与截断问题(GHSA-x82f-27x3-q89c)OpenClaw v2026.4.22:OpenShell
fs-bridge.tsOpenClaw v2026.2.26:
O_TRUNC写入标志OpenClaw v2026.3.1:
writeFileWithinRoot()打开、校验与延后截断