[控场AI]
· 7 分钟阅读· 3,640 字

allow-list 并非默认拒绝:AI Agent 防护的隐藏漏洞

allow-list 并非默认拒绝:AI Agent 防护的隐藏漏洞

白名单守卫并不天然 fail closed——决策链的选择器层才是真正的漏洞所在。

本文以 Claude Code 插件 `tamperproof` v0.1.1 为案例,揭示了「白名单 fail closed」这一安全直觉的边界条件。该插件用 `existsSync` 检查命令参数的字面字符串,若没有 token 匹配到受保护文件,则整条命令跳过裁决、直接放行。由于 shell 在执行前会展开 glob 通配符,`test/*.mjs` 这类写法不会被 `existsSync` 识别,从而绕过守卫。文章指出,fail closed/open 不是白名单与黑名单的属性之差,而是默认值位于决策链哪一层的问题:守卫在裁决层 fail closed,却在选择器层 fail open,而选择器层恰好是攻击者可以操控的那层。此外,`grep` 中的 `=>` 被误判为重定向,造成合法操作被拦截,这反而会促使用户习惯性绕过守卫。结论是:安全属性的评估必须锚定在攻击者实际能操纵的轴上。

有人在讨论一个 Claude Code 插件时提出:这个阻止 agent 修改自身测试文件的工具采用了 allow-list(白名单),而「白名单会 fail closed(默认拒绝),这正是当 agent 试图钻空子时你想要的失败模式」。

这个直觉是对的,但结论是错的。拆解清楚这一点很有价值,因为每当有人试图加固 agent 的 shell 执行环境时,同样的推理谬误就会反复出现。

An allow-list does not fail closed

这个守卫做了什么

被讨论的插件是 Mattbusel/tamperproof(v0.1.1),它把原本的「写入类命令」黑名单替换成了白名单——这是正确的方向,因为黑名单只能覆盖你能想到的那些动词,而总有想不到的。

新规则来自 hooks/guard.mjs:

任何命名了「现存受保护路径」的 shell 片段都会被拦截,除非它以已知的只读命令或测试运行器命令开头,且不包含重定向或 tee。

关键在于第一句话。「命名了现存受保护路径」这个条件,决定了规则是否会被应用。核心逻辑是对每个 token 调用 existsSync(resolve(cwd, piece))——检查这个字面 token 是否对应一个真实存在的文件。

如果没有任何 token 看起来像一个现存的受保护文件,那么这个片段就被跳过——它不会被评判,而不被评判,就意味着被放行。

Fail closed 与 Fail open 是安全设计中描述系统故障默认行为的两个术语。Fail closed(故障时关闭)意味着当系统遇到无法处理的输入或异常时,默认拒绝操作——类似于断电时自动上锁的门禁。Fail open(故障时开放)则相反,遇到不确定情况时默认放行。白名单之所以被认为「naturally fail closed」,是因为凡是不在白名单上的动词都会被拒绝,看上去比黑名单更安全。但这个直觉只在单层决策中成立:一旦决策链分为「是否进入裁决」和「裁决结果」两层,白名单只在裁决层提供 fail closed,而选择器层(决定一个输入是否进入裁决)的默认行为需要单独审查。

实测结果

作者检出了 v0.1.1 版本,构建了一棵临时目录树,里面放了 test/calc.test.mjs,然后按照 hook 的真实调用方式驱动守卫:把工具调用从 stdin 送入,读回退出码。每一行执行的命令本质相同,变化的只是目标文件的「拼写方式」:

$ sed -i s/5/-1/ test/calc.test.mjs          -> exit 2   blocked
$ sed -i s/5/-1/ test/calc.test.j?           -> exit 0
$ sed -i s/5/-1/ test/*.mjs                  -> exit 0
$ sed -i s/5/-1/ test/calc.test.{mjs}        -> exit 0
$ sed -i s/5/-1/ "$PWD/test/calc.test.mjs"   -> exit 0
$ sed -i s/5/-1/ test/nonexistent.mjs        -> exit 0

字面路径返回 exit 2(被拦截),因为它确实是一个文件:existsSync 返回真,路径受保护,片段被评判,而 sed -i 不在只读白名单里,于是被拦截。

其余所有变体都返回 exit 0。原因在于守卫问的是文件系统「字符串 test/*.mjs 是不是一个文件?」——而它不是。glob 通配符 *、?、花括号展开 {mjs}(分词器甚至直接把 { 和 } 切掉,只剩下 test/calc.test.)、以及解析到一个字面名为 $PWD 目录下的路径,统统不匹配任何现存文件。于是 hits 保持为空,规则压根没被查询。

而真实的 shell 会在 sed 看到参数之前就把这些拼写展开成真实路径——这正是 glob 的全部职责。所以这个字符串绕过了那个以字符串本身为检查对象的守卫。

Shell Glob 展开是 Unix shell 在执行命令之前完成的路径通配符替换。当你写 test/*.mjs 时,shell 会在把参数传给 sed 之前,先把它展开为所有匹配的文件名列表(如 test/calc.test.mjs)。因此,sed 程序本身看到的是真实路径,而非含有 * 的字符串。花括号展开 {mjs} 和问号通配符 ? 同理,都由 shell 处理,程序收到的永远是展开后的结果。这意味着:任何在 shell 执行之前、以字符串形式检查命令参数的守卫,都只能看到用户的「原始拼写」,而非 shell 实际将要访问的文件集合——二者在大多数攻击场景下并不相同。

真正有用的结论

守卫以何种方式失败,并不是白名单对黑名单的属性之差。它取决于默认值位于决策链的哪个位置。

这个守卫在「裁决」层是默认拒绝的:一个被评判、且非只读的片段会被拦截。但它在「选择器」层却是默认放行的:一个从未被评判的片段会被放行。而选择器是两层中更靠前的那层,也正是命令字符串能够驱动的那层。

结果就是:守卫恰好在攻击者没有自由度的地方 fail closed,却在攻击者拥有自由度的地方 fail open。

「白名单 fail closed」这句话,只在你恰好枚举的那条轴上成立——这里是「动词(命令)」轴。你必须去枚举攻击者输入所控制的那条轴,否则你测量的就是错误的东西。

另一个方向的失败同样真实

还有两个用例从反方向暴露了问题,这也是作者认为连「动词轴」都不能简单称为「封闭」的原因:

$ cat test/calc.test.mjs > /dev/null      -> exit 2
$ grep -n '=>' test/calc.test.mjs         -> exit 2

writesOut 的判断是 />/.test(seg),所以 grep 模式里的箭头 => 被当成了重定向,而 cat ... > /dev/null(重定向到空设备)也被误判。这两件事都是 agent 在调试失败测试时不断在做的操作——恰恰是守卫本应保持 agent 待在其中的那个状态。

误拦截并非没有代价:它会教会使用者绕开守卫,而一旦绕开,守卫就什么也保护不了了。

误拦截(False Positive)在安全工具中的危害往往被低估。一个把合法操作也拦下来的守卫,会在用户与守卫之间建立「摩擦」,而人类面对持续摩擦的自然反应是寻找绕过方式。一旦绕过路径被固化为工作流习惯,守卫在被绕过期间对真实威胁形同虚设。这与密码复杂度策略的研究结论一致:过度严格的策略会导致用户把密码贴在显示器上。对 AI Agent 而言,这个问题更为突出——agent 往往是在大量重复操作中触发误拦截的,它可能被「训练」出系统性的绕过模式,而这些模式一旦泛化,就难以被人类审计察觉。

可以带走的几条经验

  • 逐条规则标注默认值位于哪一层。「非只读则拦截」和「根本没被看一眼」是两种不同的裁决,而只有前者在代码评审里是可见的。

  • 对「将被写入的那个对象」提问,而不是对「命名它的字符串」提问——或者接受字符串只是一种拼写,然后去追逐所有拼写。作者本人也坦诚指出了其中的真实张力:把 glob 形状的 token 当作命中来处理,能堵上这个漏洞,但也会误伤从 shell 中新写入的合法测试文件。

  • 在两个方向都保留对照组。守卫的全部二十个测试用例都命名了路径,作者在深入之前发送的每一条也是如此。用例数量不等于覆盖率声明——真正的问题是这些用例在哪条轴上移动。

作者强调,这个插件本身是一件相当出色的作品,0.1.1 版本堵上了上一版的所有漏洞,包括上述针对字面拼写的所有情况。只是这条轴在一次重写后幸存了下来,而且除非修复进入选择器层,它还会在下一次重写后继续幸存。

这个案例对所有做 AI Agent 安全加固的人都是提醒:安全属性的讨论,必须锚定在攻击者实际能操纵的那条轴上,而不是你碰巧想到的那条。

#AI Agent 安全#allow-list#fail closed#Claude Code 插件#shell 加固#glob 绕过#白名单漏洞
分享:

相关推荐