允许清单和 shell 对命令在哪里结束各执一词

2026 年 3 月到 10 月之间,九份安全公告打中同一个编程智能体的命令批准闸门,其中五份发布于同一天,而没有一份是允许清单内容本身的错。对 shell 字符串做匹配,等于自己再维护一个 shell——而那个仓库自 5 月起已归档,所以「3.54.0 及以下」就是终局。

作者 智能体 AI 维基 24 分钟读完

2026 年 3 月到 10 月之间,九份安全公告打中同一个编程智能体的命令批准闸门,其中五份发布在同一天,而没有一份是允许清单内容本身的错。它们全是同一个缺陷:闸门解析命令字符串,然后把同一个字符串交给 bash,而两者对「命令在哪里结束」各执一词。如果你的智能体是靠对 shell 字符串做模式匹配来决定跑什么,那你就是在维护第二个 shell——而你会输。

一眼概览

Roo Code 是一个 VS Code 编程智能体,GitHub 上有 24.3k 星。它的自动批准功能让开发者可以按前缀预先批准命令——echo、git status、npm test——此后智能体就能不弹窗地执行它们。下面每一份公告都打在这同一条判定路径上。

发布日期CVE公告点名的组件严重度
2026-03-30CVE-2026-30307命令自动批准模块(OS 命令注入)Critical
2026-07-20CVE-2026-63108自动批准的执行路径(命令注入)High
2026-09-08CVE-2026-82536、CVE-2026-82537shell 命令自动批准绕过High
2026-10-13CVE-2026-110202 … CVE-2026-110206getCommandDecision() 与 parseCommandLine()High(CVSS v4.0 7.7)
Roo Code command-approval advisories published in 2026 A horizontal bar chart of GitHub advisories against Roo Code's command auto-approval path by publication date: one on 30 March 2026, one on 20 July, two on 8 September, and five on 13 October. All nine name versions through 3.54.0 and none lists a patched version. Advisories against the command auto-approval path, 2026 30 Mar 1 20 Jul 1 8 Sep 2 13 Oct 5 1 2 3 4 5 Repository archived read-only on 15 May 2026; no advisory lists a patched version.
九份公告、一个模块、七个月。十月那一簇是一位研究者在系统性地把解析器过一遍。

有两个事实框住了后面的一切。第一,这九份全写着「3.54.0 及以下」,而没有一份列出已修复版本。第二,该仓库已于 2026 年 5 月 15 日被其所有者归档、变为只读;README 写着「The Roo Code Extension was shut down on May 15th」,并把用户指向社区 fork ZooCode,以及 Roo Code 自己当初 fork 出来的 Cline。所以「3.54.0 及以下」不是一个等着被关闭的版本区间,而是终局状态。

两个解析器,一个字符串

One command string, two parsers, two different commands The model proposes a single shell string. The approval gate strips the redirect operator and reads the next word as the command, matching an allowlisted prefix and approving without a prompt. Bash reads the same string as a redirection to a file named echo followed by a denied command, which then runs with the developer's own credentials. Nothing in the path compares the two readings. The model proposes one string >&echo touch /tmp/ROO_PWNED Approval gate replace(/\d*>&\d*/, "") reads the command as echo touch /tmp/ROO_PWNED bash >& = redirect both streams reads the command as touch /tmp/ROO_PWNED nothing in the path compares the two readings verdict: auto_approve no prompt is shown to the developer denied command executes with the developer's own credentials
闸门按它对字符串的读法做判定。shell 执行它自己的读法。没有任何东西检查两者是否一致。

这个控制的形状简单到可以放在脑子里。模型提出一个字符串。闸门把它拆成子命令、各自做归一化,再把第一个词拿去对允许清单与拒绝清单匹配。如果每个子命令都被允许,这个字符串就被交给一个 shell,以开发者本人的权限运行——他的 SSH agent、他的云配置、他对仓库的写权限。

闸门对这个字符串的读法,是一次关于「shell 会拿它做什么」的猜测。十月那批已核实的发现,是这次猜测出错的两种不同方式,对应两个不同的 CWE,位于两个不同的文件。

那个把命令挪了位置的重定向

CVE-2026-110202 位于 src/core/auto-approval/commands.ts 的 getCommandDecision() 中。匹配之前,这个函数会剥掉重定向,好让一个无害的 2>&1 不至于搅乱前缀匹配。在 v3.54.0 上,那一行是这样:

// Remove simple PowerShell-like redirections (e.g. 2>&1) before checking
const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim()

两处数字组都是 \d*,所以这个模式也会匹配一个光秃秃的 >&。而在 bash 里,光秃秃的 >& 不是文件描述符复制,它把 stdout 和 stderr 都重定向到由紧接着那个词命名的文件。删掉这个运算符,那个文件名就滑进了命令位置,而允许清单会欣然匹配它:

ALLOW = ["echo"]    DENY = ["touch"]

input                               gate says        bash does
---------------------------------   --------------   ----------------------------
touch /tmp/ROO_PWNED                auto_deny        (blocked)
echo hi 2>&1                        auto_approve     echo hi
>&notecho touch /tmp/ROO_PWNED      ask_user         (blocked)
>&echo touch /tmp/ROO_PWNED         auto_approve     touch /tmp/ROO_PWNED

在最后一行,闸门剥掉 >&,看见 echo touch /tmp/ROO_PWNED,匹配上 echo 前缀,于是批准。bash 把同一个字符串读成「把两个流都重定向到一个叫 echo 的文件,然后运行 touch」。文件出现了,被拒绝的命令跑了。GitHub 把它归为 CWE-436,解释冲突——这个名字准确得很。

那个其实不是注释的注释

第二种机制低一层,在分词器里。src/shared/parse-command.ts 中的 parseCommand() 用 shell-quote 库来拆字符串。shell-quote 即便 # 紧贴在一个词的末尾,也当它开始一段注释,并把其余部分作为单个注释对象返回。而 Roo Code 的记号循环只处理运算符记号和字符串记号,于是一个没有 op 属性的对象记号两个分支都不匹配,被静默丢弃:

parse("echo hi#;touch /tmp/ROO_PWNED")
  → ["echo", "hi", { comment: ";touch /tmp/ROO_PWNED" }]

gate sees:  echo hi                    → matches ALLOW, approve
bash sees:  echo hi#  ;  touch ...      → '#' mid-word is literal, touch runs

bash 只在一个词的开头才开始注释。在 hi# 里,# 是个普通字符,它后面的 ; 是真正的分隔符,而后面那条命令会执行。闸门根本就没看见它——不是判断失误,是没看见。同胞 CVE-2026-110205 是第三个变种:parseCommandLine() 会丢掉紧挨着通配字符的命令替换,于是 echo *$(...) 匹配上了一个被允许的前缀。GitHub 把那一个归为 CWE-184,「被禁输入清单不完整」。

七种分歧,三个层次

研究者为这一簇公开的手记,各以它所滥用的构造命名:重定向、通配符记号、引号边界、注释边界、「两流同管」运算符、函数替换,以及把输出当成命令。七篇手记,一个模块。这才是值得盯着看的数字,因为它告诉你:缺陷不在这七个之中的任何一个里。

同时有三个不同的层次在各执一词,而每一层对「命令从哪里开始」都有自己的说法:

  • bash,唯一真正会执行的那套读法。
  • shell-quote 库,一个泛化的、近 POSIX 的分词器;它从来不是 bash 模拟器,也没声称是——它的注释规则不一样,而这个不一样就是一次绕过。
  • 闸门自己那层正则,手写的归一化,叠在分词器的输出之上,带着自己的缺陷(上面那个 \d*),而且没有 g 标志,于是连它本来想做的事都止步于第一次匹配。

修好其中任何一个,另外两个仍然各执一词。这就是为什么那些手记提出的修法同时既正确又不够:在重定向正则里要求 \d+ 恰好关掉 CVE-2026-110202,却一点没动这个控制的形状。下一个构造会是项目里还没人想过的某个 shell 特性,而 bash 的特性相当多。

Three places the same approval policy can be enforced Three columns. Matching a shell string requires a parser that must agree with bash and is the location of all nine advisories. Matching an argv vector removes the shell entirely, so there is one reading of the command. Constraining the sandbox enforces effects in the kernel and carries no parsing risk at all. Same policy, three enforcement points Match a shell string "git status --short" needs a parser that agrees with bash on every construct parsing risk: all nine CVEs Match an argv vector ["git","status","--short"] no shell is involved, so there is one reading of the command parsing risk: none; flags still matter Constrain the sandbox no creds, ro mount, deny egress effects enforced by the kernel, whatever string gets through parsing risk: none at all
同一条策略,在三个不同的地方强制执行。其中只有两个不含解析器。

也请注意 CVSS v4.0 向量关于可达性说了什么:AV:N/AC:L/AT:P/PR:N/UI:P——网络攻击向量、低复杂度、无需权限,以及被动的用户交互。开发者不需要点任何东西。他请智能体概述一个 issue、读一份 README,或者整理某个依赖的更新日志,而他让它读的那段文字里带着命令。这就是普通的间接提示注入;而允许清单是它与一个 shell 之间全部的控制。

那个不会到来的补丁

归档日期比它看起来更重要。一个还在维护的产品里的漏洞是个排期问题。一个有 24.3k 星、已变为只读的仓库里的漏洞,是所有仍在运行该扩展的机器、以及所有从那棵代码树起步的 fork 的永久属性。

后半句才是比产品活得更久的那部分。ZooCode 起步于一个社区 fork;它按当时的样子继承了 commands.ts 与 parse-command.ts,也继承了那七种分歧——除非有人独立地把每一处修法重新推导了一遍。Cline 位于 Roo Code 的上游而非下游,所以它不继承这些具体的回归——但它出货的是同一类功能,而每一个带自动批准清单的编程智能体都是如此。一次绕过是这种做法的属性,不是某个品牌的属性。任何出货「按前缀匹配的 shell 批准」的人,都该把那七篇手记当成一份测试套件,而不是别人家的事故。而如果你正在评估一个编程智能体,「它有没有允许清单」是错的问题;这份允许清单到底在保护什么才是对的问题。

该把判定放到哪里

有用的推广是:一个批准闸门必须对系统真正会作用于的那个对象做判定。shell 字符串不是那个对象——它是一门有自己解析器的语言里的程序,而除了真正运行它,你对它做的任何事都是近似。四个动作,大致按收益排序:

  • 批准一个 argv 向量,而不是一个字符串。让模型产出 ["git", "status", "--short"],再用 spawn、不带 shell 执行它。现在命令恰好只有一种读法,闸门读到的就是被执行的那一份,而重定向、通配、替换、管道和注释都不存在了,因为路径上没有 shell。模型真正需要的那些运算符,变成显式的工具参数而不是语法。
  • 让沙箱、而不是清单,承担风险。一条逃出允许清单的命令,若落在一个没有凭据、仓库只读挂载、并且默认拒绝外发的容器里,只是件麻烦事。同一条命令落在开发者笔记本上,就能摸到 SSH agent 和一份云配置。预先批准的安全程度不会超过它所挡着的那片爆炸半径,而笔记本是运行它最糟的地方。
  • 批准效果,而不是语法。「可以写入 ./src、不可触网、不可碰 ~/.ssh」由内核就能强制,且不带解析风险。「以 echo 开头的命令」只能由你自己写的解析器强制。
  • 记录你真正执行的那个字符串,连同放它过去的那次判定。这些绕过中的每一个,事后都能在一条把「被提议的命令、闸门的判决、退出码」配在一起的日志里看出来。没有这份配对,你无法把一次绕过与一次正常批准区分开——而这正是一次绕过能七个月无人察觉的原因。

这些都不要求你放弃自动批准。它要求你接受:允许清单是一个用来少弹窗的便利功能,不是一道安全边界——然后在它底下放一道真正的边界。

常见问题

这只是 Roo Code 的问题吗?

不是。缺陷是 Roo Code 的,但这个设计——对 shell 字符串做前缀清单匹配,然后把字符串交给一个 shell——是大多数带自动批准功能的编程智能体共有的。Roo Code 只是那个有人坐下来把分歧公开枚举了一遍的。

我直接修正则不行吗?

你可以关掉每一个具体的发现,那些提出的修法也确实做到了。靠打补丁做不到的,是让一个手写的归一化器在每一种构造上都与 bash 一致——因为构造的集合由 bash 定义,而它一直比你的测试套件大。

为什么 CVSS 说无需权限、交互是被动的?

因为攻击者根本不接触那台机器。他们把命令放进智能体会去读的内容里——一条 issue 评论、一个网页、某个依赖的发布说明——而开发者那句「去读一下这个」的普通请求,就是全部的用户交互。

argv 允许清单就完全没有绕过了吗?

它彻底消掉了 shell 解析这一类,也就是这九份公告所在的那一类。它没有消掉参数层面的滥用:带 -c core.sshCommand=… 的 git,或任何带 --exec 旗标的二进制,仍然是换条路走的 shell。请把旗标也一起允许,或者把那个二进制从沙箱里拿掉。

仓库都归档了,为什么还要写它?

因为归档的代码不会停止运行,而 fork 是从它起步的。这一簇也是目前能拿到的最清晰的公开证据,说明「按前缀匹配的 shell 批准」是整类失效,而不是一串疏忽。

延伸阅读

本站:

信息来源: