ARTICLE / ai
把删除命令藏进一条“没有命令”的语句里:编码Agent权限策略的扫描盲区
一个编码 Agent 配好了最严格的 shell 策略:除白名单外一切命令拒绝执行。测试时 cat 被拦、rm 被拦、git push 被拦。然后有人在 Windows 上输入了这么一行:
文件删了。没有弹窗,没有拒绝记录,权限系统压根没出手。
这不是配置错误。OpenCode(anomalyco/opencode)社区在 9 月到 11 月间接连报告了同一根因的四五个变体,维护者机器人在最新一份重复报告下确认了诊断,修复 PR #60376 已于 10 月 29 日合入 dev 分支。这条线索值得安全从业者完整走一遍,因为它演示的是所有"先解析再授权"架构共同的失效模式:检查逻辑建立在"能解析出东西"的前提上,而攻击面恰好藏在解析不出东西的语法里。
防线是怎么搭的
OpenCode v2 对 shell 工具的做法是典型的静态分析加策略匹配:命令字符串先过一层扫描器(scanner),解析成若干"命令资源",每个资源拿去和权限规则比对——命中 deny 就拒绝,命中 ask 就弹窗,全放行才执行。文档承诺得很清楚:语法畸形或包含动态求值、扫描器无法安全分析的命令,应当返回扫描器错误而不是直接执行未验证的子命令。
预期中的失败方式是"看不懂就不许跑"。实际的失败方式是"看不懂就当没有"。
以 issue #49948 报告的裸重定向为例。POSIX 语法里 > victim.txt 是一条完全合法的语句——没有命令词,只有一个输出重定向,效果是把文件清空。扫描器对它做了什么呢?两代扫描器都返回"0 条命令"。这本身不算错,它确实不含可执行的外部程序。问题出在下一步:shell 工具的代码只在扫描结果非空时才调用权限检查。0 条命令,跳过检查,原样执行。
更微妙的是权限引擎自身的默认行为。报告者顺手验证了引擎层的配对逻辑:当传入的资源列表为空时,“取各资源效果、有 deny 则 deny、有 ask 则 ask、否则 allow"这段判断会走到最后的 allow——哪怕你配的是一条覆盖一切的 {action:"*", resource:"*", effect:"deny"}。空集合上没有 deny 可言。报告者把这层称为"fail-open 是真实存在而非理论推演”,并强调正因为引擎是这个语义,工具层那个"非空才检查"的判断才成了整条防线的承重墙——也恰恰是它的失守让危险语句直接落地。
同一个洞的五种长相
真正让人后背发凉的是这个盲区在不同 shell 里的投影数量。下面这张对照表由两份独立报告拼成——前三行来自 #49948 报告者在 POSIX shell 下对 "*": deny 的测试;后三行来自 #60958 报告者在 Windows 10 Pro、opencode 2.1.4、单条 shell:* deny(无白名单)环境下的 PowerShell 测试。平台与配置不同,但失效模式完全一致:
| 语句 | 扫描器产出 | 实际行为 |
|---|---|---|
rm -rf x | 1 条命令 | 正常拦截 |
echo hi > out.txt | 1 条命令 | 正常拦截 |
> victim.txt(裸重定向) | 0 条 | 直接执行,文件清空 |
Write-Output "System.IO.test" | 1 条 | 被 *System.IO.* 规则拦截 |
[System.IO.File]::WriteAllText("C:\temp\probe.txt", "x") | 0 条 | 直接执行,文件创建 |
[System.IO.File]::Delete("C:\temp\probe.txt") | 0 条 | 直接执行,文件删除 |
规律一目了然:管道、循环、命令行程序都会被解析出"命令",两份报告实测解析不出的形态有两类——纯重定向,以及 PowerShell 的裸 .NET 表达式语句;修复 PR #60376 还枚举了声明重定向(export -p > ~/.zshrc)、组文件重定向({ X=1; } > ~/.zshrc)等更多零命令逃逸。后者尤其值得注意,因为 [System.IO.File]::Delete()、$proc.Kill()、[scriptblock]::Create(...).Invoke() 这些成员方法调用是完全有效的破坏性操作,PowerShell 语法却不把它们当"命令"。10 月 31 日的又一份报告(#60958)给出了同样的 PowerShell 对照表,并补了一句关键排除:通配符匹配没问题、策略引擎在转、普通命令同会话全部被拦——表达式语句只是"根本没产生可供匹配的资源"。
维护者侧的处理也干脆:#60958 被判定为 #49948 的重复,机器人确认同一 bug、指向修复 PR 并说明尚未进入 release。
修的是什么,没修的是什么
PR #60376 的标题概括了收口范围:重定向、PowerShell 方法调用、动态目录切换三类此前逃逸的形态,现在都会产出显式的权限资源参与匹配。diff 摘要里能看到修复路径:在扫描器里新增方法调用、成员链起点等识别逻辑,让此前解析不出的形态产出可匹配的资源——[IO.File]::Delete(* 这样的前缀资源、>> * 这样的重定向资源被明确生成出来,交给策略引擎裁决。真正解析不出的形态仍走原来的跳过路径,报告者建议的"原始调用串兜底评估"没有被采纳。cd $dir、cd {..,x} 这类变量目录切换不再被丢弃或错误归一化,~user 展开改为查询真实家目录而不是凭空构造路径。
两处边界值得说清。第一,截至该 PR 合入 dev 时,修复还没有进入任何正式 release——报告环境里的 2.1.4 用户在修复进入 release 前依然暴露。第二,也是更根本的一点:这次修复消灭的是"已知解析不出"的形态,而"先解析再授权"的架构意味着每一门 shell 方言、每一次语法扩展、每一个新解析器实现都是待验证的攻击面。#49948 报告者选择的期望行为措辞很有分量——“无法分解的命令仍应以原始调用串作为资源接受评估,fail closed 对权限边界而言显然是正确的”。扫描器永远可能漏,兜底应该是漏掉的东西默认无权限,而不是默认没意见。
给自建 Agent 权限层的三条自查
如果你正在给自己的 Agent 框架做 shell 管控,这条线索能直接换算成三件事:
一、拿本次案例的对照表当回归用例。裸重定向、PowerShell 纯表达式语句这两类是两份报告实测过的失效形态;修复 PR #60376 还列出了 && 后的尾部重定向、变量目录切换等更多逃逸形态——每类挑一条塞进你的解析器测试,看它们是产生权限事件还是静默通过。凡是"解析结果为空"的分支,检查下游到底是拒绝、上报,还是像本案一样放行。
二、审一遍"空集合语义"。deny-all 策略遇到零个资源时的返回值,是整套防线里最少被测试、后果最重的一格。资源为空时按最严格解释处理,比指望上游永远解析得动要便宜得多。
三、把"检查发生在哪一层"画出来。本案的教训是解析层和授权层分属两个模块,解析层漏一个语法形态,授权层就整个旁路——一边修好不代表另一边跟上。分层防线要做跨层关联核对,单层的绿不等于整体的绿。
以上绕过形态均来自上游已公开披露的历史缺陷。本文面向授权安全研究与防御建设,未经授权对任何系统实施文中技术属违法行为。
参考来源:anomalyco/opencode issue #49948、#60958 与 PR #60376 原始页面(采集快照 2026-11-03)。