自动批准与命令允许清单

A47
概念 · 智能体 AI 详解

自动批准与命令允许清单。

一份拿 shell 字符串做匹配的允许清单,本质上是你自己又维护了一个 shell 解析器;一旦它和真正的那个各执一词,这次批准就毫无价值——这正是 2026 年 3 月到 10 月之间有九份安全公告打中同一个编程智能体的批准闸门,而其中没有一份是清单内容写错了的原因。请批准你真正会执行的那个对象(通常是一个 argv 向量),并让沙箱、而不是清单,来承担判断失误的后果。

步骤 1自动批准是为了什么,又会悄悄变成什么。

任何能动手的智能体,都得逐个动作地决定:是直接做,还是先问。每次都问就毁掉了全部意义——一个连 git status 都要停下来请示的编程智能体,比你自己动手还慢。于是各家框架都附带一种预先批准机制:一份用户事先祝福过的命令、工具或路径前缀清单,之后智能体就静默执行。Claude Code 的权限规则、Cursor 的命令允许清单,以及编辑器智能体里那个「总是批准执行」的开关,说的都是这件事。

它出生时是个便利功能:对用户本来就信任的动作少弹几次窗。大约一周之后,它就变成了安全边界,因为清单另一侧是一个带着开发者自己的 SSH agent、云凭据和仓库写权限运行的 shell。没有人决定过这次「晋升」;它之所以发生,只是因为清单是模型输出与真正执行之间唯一挡着的东西。而这两种角色想要的设计恰好相反。便利功能应当宽松、宽容;边界应当精确,而精确恰恰是字符串匹配给不了的东西。

判断你的清单在扮演哪个角色,有一个测试:如果下一次调用绕过了这份清单,你需要启动事故响应吗?如果需要,它就是边界,于是它需要具备引用监视器的性质——不可绕过、总被调用、小到可以验证——而不是一个配置文件的性质。

步骤 2为什么匹配命令字符串是整类失效。

一条 shell 命令是一门语言里的程序,这门语言有运算符、引号、替换、重定向、通配和注释。要判断被执行的命令是不是 foo,你必须解析这门语言。如果你的闸门按一种方式解析,而你随后把同一个字符串交给的 shell 按另一种方式解析,那么闸门批准了一条从未运行的命令,而 shell 运行了一条从未被批准的命令。CWE-436 把这叫做解释冲突(interpretation conflict),这正是整个类别最贴切的名字。

Roo Code 是这里的标准例题,因为有人把它公开地枚举了一遍。2026 年 3 月 30 日到 10 月 13 日之间共九份 GitHub 公告——其中五份发布在 10 月那一天——全部落在同一条判定路径上,而七篇研究者手记点名了七种互不相同的构造:重定向、通配符记号、引号边界、注释边界、「两流同管」运算符、命令替换,以及把输出当成命令。其中有一个小到可以完整读完。该闸门在匹配前先剥掉重定向,用的是 /\d*>&\d*/,好让无害的 2>&1 不至于破坏前缀匹配。两处数字组都是可选的,于是这个模式也会吃掉一个光秃秃的 >&——而在 bash 里,它不是描述符复制,而是「把两个流都送进紧接着的那个文件名」:

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

# gate strips ">&", sees `echo touch ...`, matches ALLOW, approves.
# bash reads: redirect both streams to a file named "echo", then run touch.
>&echo touch /tmp/PWNED        gate: auto_approve   bash: touch runs

第二个变种在更低一层。分词用的是一个通用的 shell-quote 库,它即便在词中间遇到 # 也当成注释开始,并把其余部分作为一个注释记号返回,而闸门的循环随后把它丢掉了。bash 只在一个词的开头才开始注释,所以在 echo hi#;touch /tmp/PWNED 里,# 是字面字符,; 是真正的分隔符,而闸门根本没看见第二条命令。三个层次——bash、分词库、闸门自己那层正则归一化——各自抱着一套关于「命令从哪里开始」的理论。

教训不是「把正则写好一点」。每个单独的修复都是对的,而且恰好关掉一个发现。shell 构造的集合由 bash 定义,它比你的测试套件大,而且还在长。一个正确性取决于「把一个不属于你的 shell 解析得更准」的控制,没有稳定态。

步骤 3批准你真正执行的那个对象。

修法是结构性的,而且很便宜:让闸门检查的东西,就是操作系统要运行的东西。落到实践上,就是让模型产出一个 argv 向量,并直接执行它,路径上没有 shell。

# Fragile: one string, two readers, and only one of them executes.
decision = gate(cmd_string)
if decision == "approve": subprocess.run(cmd_string, shell=True)

# Sound: the gate inspects argv[0] and the flags that will actually be exec'd.
argv = ["git", "status", "--short"]
if gate(argv): subprocess.run(argv, shell=False)   # no shell: no redirects,
                                                 # globs, substitution, comments

没有 shell,那九份公告的成因所依赖的构造就都不存在。当智能体确实需要重定向、管道和通配时,它们变成显式的工具参数——{"cmd": [...], "stdout_to": "out.txt"}——这也让它们对闸门可见,而不是藏在一个字符串里。于是三条性质自动成立:命令只有一种读法;闸门读到的就是被执行的那一份;审阅者能从日志里看出究竟跑了什么。

argv 匹配消掉了解析这一类问题,但没消掉参数滥用。git -c core.sshCommand=…、find -exec,以及任何带 --eval 旗标的东西,都是换条路走的 shell;所以一份认真的清单会连旗标一起允许,或者干脆把危险的二进制从环境里拿掉。这是一个关于某个具体工具的、有限且可枚举的问题——而这恰恰是字符串匹配那个版本从来不是的东西。

步骤 4把风险放在解析不可能出错的地方。

即便一份允许清单完全正确,它也是承担后果的错误位置。清单是关于语法的陈述;而你真正在意的是效果——什么被读了、被写了、被发出去了。效果可以由内核强制,不带解析风险,并且无论哪个字符串溜了过去都仍然成立:

  • 在沙箱里跑,别在笔记本上跑。在一个没有凭据、只读挂载、并且默认拒绝外发的容器里,一次绕过只是件麻烦事。同一次绕过发生在开发者工作站上,就能摸到 SSH agent 和一份云配置——见开发者工作站上的智能体与沙箱与代码执行。
  • 按爆炸半径来定清单大小,而不是反过来。预先批准的安全程度,不会超过它所挡着的那片东西;该问的从来不是「echo 安不安全」,而是一次被批准的调用能摸到什么。
  • 假定这条命令来自攻击者。十月那一批公开的 CVSS 向量记录的是被动的用户交互、且不需要任何权限:开发者请智能体读一个 issue 或一份更新日志,而那段文字里带着命令。这就是普通的间接提示注入;一个同时拥有不受信输入、私有数据与一个 shell 的智能体,就是致命三要素,而允许清单是它唯一的缓解措施。
  • 失败即关闭,并把成对的记录写下来。无法解析或不认识的命令就去问人;见失败即关闭与失败即放行。然后把被提议的命令、判决和退出码一起记录下来——没有这份配对,一次绕过和一次正常批准毫无区别,而这正是一次绕过能七个月无人察觉的原因。

作用域同样重要,而它通常开得太宽:为一个任务授予的规则,往往会在整个会话里一直留着。宁可按任务授予,而不是按会话授予;作用域变了就重新问一次;并让这份授予对用户可读——这件事的体验那一半是批准与确认体验,策略那一半是策略即代码。

这周做三件事。把闸门的输入从命令字符串换成 argv 向量,并用 shell=False 执行。把智能体的 shell 搬进一个没有环境凭据、默认拒绝外发的容器,这样一次绕过的代价是重建一个容器,而不是一次事故。然后为每次判定加一行日志,把被提议的命令和判决配在一起——如果你无法仅凭日志重建出上周允许清单批准过的每一条命令,那你拥有的不是一个边界,而是一个开关。现场证据见《允许清单和 shell 对命令在哪里结束各执一词》。