几乎所有路径检查防护都无法抵御符号链接穿越:allowed目录下创建一个指向目录外的symlink,即可将文件写出白名单范围。path.resolve和path.relative是字符串运算,不追踪真实文件系统路径。
几乎每个写保护最终都会用到下面这个路径检查,包括我自己写的那个。它比明显的版本好一些,但仍然有问题。
import { isAbsolute, relative, resolve } from "node:path";
function contains(root, target) {
const rel = relative(resolve(root), resolve(target));
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
它正确地拒绝了 ../../etc/passwd。当只允许写入 src 目录时,它也正确地拒绝了 src-secret/keys.json——而那个 naive 的 target.startsWith(root) 在这里会出错。如果你写过这个函数,大概测试过这两种情况,看到它们被拒绝,然后就继续往下走了。
现在,在允许的目录里放一个符号链接:
project/
products/ <- 唯一允许 agent 写入的目录
cache -> ../../secrets
secrets/
notes.txt
然后问保护机制:project/products/cache/notes.txt 能不能写:
1. lexical guard -> defer (approved)
2. write landed in C:\...\escape-UlmQ6t\secrets\notes.txt — outside products/
这是真实复现过程中的记录,第二行才是关键:文件被写入了,而且写到了 allow-list 之外。保护机制不是没有运行——它运行了,检查了路径,然后说可以。
path.resolve 和 path.relative 是字符串操作。它们会折叠 . 和 ..,规范化分隔符,但从来不真正打开任何东西。resolve 一个路径是对文本的一个声明,而不是对文件系统的声明。
所以 products/cache/notes.txt 规范化之后还是 products/cache/notes.txt,它位于 products 之下,而 products 是允许的。每一步都是正确的。错误在比较操作的上游:保护机制在比较两个名字,而它真正想约束的是两个位置——在有链接的文件系统上,这两者不是同一回事。
这不是经典的路径遍历漏洞。遍历漏洞是输入里包含 ..,上面的检查能抓住它。这里输入里没有任何可疑的东西——没有 ..,没有绝对路径,没有编码技巧,reviewer 不会在任何一个地方画圈。重定向发生在磁盘上,不在字符串里。
谁会在那里放链接
这件事值得说精确一点,因为"攻击者植入了一个符号链接"并不是常见情况——假装它是会让这个风险很容易被Dismiss掉。
项目目录里的链接是再普通不过的东西。node_modules 里全是链接,而 pnpm 的整个目录布局本身就是靠符号链接撑起来的。构建工具把缓存链接到指定位置。Monorepo 工具链把各个包互相链接起来。任何曾经把 data/ 目录指向更大磁盘的人都这么干过。如果你的 allow-list 是 products/,而它下面有链接,保护机制就有了一个洞——但这不是谁的错。
然后还有 agent 本身。一个无人值守的 agent 会运行 shell 命令,而 shell 命令里创建目录链接不是 Write 操作——钩子根本看不到它,因为匹配器匹配的是工具名称,而 Bash 不属于文件写入工具之一。一句 ln -s 就建立好了这条路由,之后所有经由它的写入都会被保护机制批准——而这个保护机制做的工作正是它被设计来做的事。
我不是说这让钩子变成了沙箱。它从来就不是——一个能跑 shell 的 agent 可以随意写到任何地方,根本不咨询你的钩子,这也是为什么要有 git-diff 检查。说法要更窄、更糟糕:保护机制在这里是沉默的。它把写入报为"在范围内",这和一切正常时的报告一模一样。
Windows 让它更容易,而不是更难
直觉会认为这是 Unix 问题,因为在 Windows 上创建符号链接需要管理员权限或开发者模式。对于符号链接来说确实如此——但无关紧要,因为链接不是 Windows 唯一支持的链接类型。
在关闭开发者模式的 Windows 10 上探测:
FAILED TO CREATE: EPERM dir link (symlink)
FAILED TO CREATE: EPERM file link (symlink)
defer dir link (junction)
两种符号链接类型都被拒绝了。目录 junction 没有任何提示、不需要提权,就被普通用户进程创建了出来——而这个 junction 才是打败保护机制的那个。fs.symlinkSync(target, path, "junction") 是 Node 里一行代码,mklink /J 是 shell 里一行命令。每个人记得的那个权限门,守的是那扇本来就锁好的门。
diff 也抓不到这个
对于"钩子看不到一切"的标准答案是:额外在提交前 diff 一下 git status,然后把改动的路径和同一个 allow-list 比一比。我用那个逃逸跑了一遍看能抓到多少:
git status --porcelain:
?? products/cache/
checkScope -> {"ok":true,"changed":["products/cache/"],"violations":[]}
什么都没抓到。Git 报告的路径是 products/cache/,在 allow-list 里面,所以 scope 检查通过了。两层保护机制都在用同样的方式比较同样的名字,所以它们共享同一个盲点——两个独立的检查以相同方式失败,就是一个检查穿了件马甲。
修复方案,以及它会引入的 bug
问问文件系统这个路径实际上指向哪里:
import { realpathSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
export function resolveLinks(path, realpath = realpathSync) {
let current = resolve(path);
const tail = [];
for (;;) {
try {
return tail.length ? join(realpath(current), ...tail) : realpath(current);
} catch (err) {
if (err?.code !== "ENOENT" && err?.code !== "ENOTDIR") return null;
const parent = dirname(current);
if (parent === current) return resolve(path);
tail.unshift(basename(current));
current = parent;
}
}
}
这个循环之所以需要,是因为 Write 的目标通常还不存在——这本来就是 Write 的意义——而对不存在的路径调用 realpath 会抛出 ENOENT。所以一直走到最近的存在着的祖先那里再解析,然后把跳过的段落重新接回去。这些段落不可能藏着链接,因为它们根本不存在。
现在说重要的部分,这是在第二次复现时才看到的。把解析后的目标喂给旧的比较逻辑,合法写入开始被拒绝:
5. resolving the target only -> deny on a legitimate write
both sides resolved -> defer
如果项目根目录本身也是通过链接到达的,目标解析到真实位置,而 allow-list 的根没有解析,它们就不再共享前缀,一切都被拒绝了。这不是稀罕情况:macOS 上 /tmp 就是 /private/tmp,/home 常常是挂载点后面藏着链接,CI 的 checkout 路径也经常是指向 workspace 目录的链接。两边都解析,或者都不解析。只解析一边比都不解析还糟糕,因为它在普通情况下就失败了,而不是在罕见情况下。
两个更小的决定,都值得主动做一下:
当 realpath 拒绝回答时,拒绝。符号链接循环给出 ELOOP;读不了的目录给出 EACCES。在钩子的其他地方,正确的失败策略是继续往下走——一个刚输错 allow-list 就拦住所有写入的保护机制,你到午饭时间就会把它关掉,而且钩子契约里每个失败路径最终都是"继续"。这一个案例是例外,原因是这不是保护机制的失败,而是文件系统拒绝说出某个路径指向哪里。一个未知位置不是可以批准的那个。
把解析后的路径放进拒绝消息里。有意义的拒绝是那些实际路径和请求路径不一致的情况,因为那个差异就是链接:
Write to C:\...\escape-UlmQ6t\project\products\cache\notes.txt is outside this
agent's write scope. It resolves to C:\...\escape-UlmQ6t\secrets\notes.txt.
Allowed: products. If this file genuinely needs changing, say so and stop — do
not work around the guard.
模型会读到这串字,你凌晨三点也会读到。
这仍然没修掉什么
检查发生在钩子里;写入发生在钩子返回之后。在这两个时刻之间,链接可以被创建或重新指向,而 PreToolUse 钩子没有任何办法关上那扇窗——这是 time-of-check-to-time-of-use 窗口,是通过名称检查路径这件事固有的问题。真正的边界是操作系统层面的:容器、看不到磁盘其他部分的用户账号、文件系统命名空间。如果你有的只是一个钩子,那你有的就是对失误和对 agent 从它读到的文件里捡到的指令的保护——它值得有,但它不是沙箱。
改变的是那个沉默。以前保护机制批准了写入并报为"在范围内"。现在它拒绝并说出路径实际上去了哪里。
这个 bug 能在 review 里活下来的原因是:测试套件只断言了保护机制允许正确的操作并拒绝明显错误的那几个。每个断言都通过了,有洞之前和之后都是,因为谁都不知道链接是一个类别。
所以:在临时目录里创建一个真正的链接,把写入指向它,然后断言"拒绝"。不是 mock 的 realpath——是真实文件系统上的链接,Windows 上是 junction,其他地方是 dir 链接,如果平台拒绝创建就打印一个 SKIP 跳过。而且在你信任新测试之前,故意把保护机制弄坏,看它会不会失败。一个从来没被看到失败过的检查不构成任何证据。
Originally published at fewparts.co.uk.
Agent Guardrails Kit 是这个代码的免费组装版本——同样的模块,连在一起,加上测试。