解析不出命令名,就别放行——命令解析是 AI Agent 的安全边界
Shellby 复盘:AI Agent 执行命令前按只读/变更/破坏分级,判定全靠「从命令行里解析出真实命令名」。而 sudo 选项、su 提权、环境赋值前缀、引号里的竖线,每一个都能骗过朴素解析。这篇讲为什么解析器本身就是安全边界,以及解析不出来时的正确默认是「拒绝」。
之前写过 Shellby 的 AI Agent 三级审批门:命令执行前由一个本地的 CommandClassifier 分级——只读自动放行、变更点击批准、破坏强确认——再叠用户白/黑名单。那篇讲的是审批架构。这篇讲一个更底层、也更要命的问题:这套判定的正确性,完全押在「能不能从命令行里解析出真实的命令名」上。而解析器本身,就是安全边界。
本周把这个解析器修了一轮,每个 bug 都是「解析错 → 判定错 → 要么误放行、要么误拦」。
判定的地基是「取命令名」
分级的顺序是这样(保守优先):
- 内置破坏模式一票否决——
rm -rf、dd、管道到sh、重定向到系统路径这些,优先级最高,白名单打不穿(你把rm加了白名单,rm -rf /照样判破坏); - 命中用户黑名单 → 破坏;
isPureReadOnly:整条命令是「内置只读集 ∪ 用户白名单」的简单管道 / 串联,且无重定向、命令替换、后台 → 只读;- 其余一律 → 变更(保守:真只读但复杂的命令,宁可多一次审批)。
第 2、3、4 步全都要先回答一个问题:这一段的命令名到底是什么? allowlistCandidates、commandUsesDenied、isReadOnlyPipeline 三处判定,统一收敛到一个解析器 segmentCommandToken。它解错了,上面整套判定跟着错。
提权前缀:解析不出来,就返回 nil
最危险的一类是提权。朴素解析「取第一个 token 当命令名,顺手剥掉 sudo」会被这样骗过:
sudo -u deploy pm2 list # 第一个 token 是 sudo,剥掉,第二个是 -u……
如果你为了让某个选项通过、把 -u 加进了白名单,朴素解析就会把 -u、pm2 当成只读命令自动放行——而这条命令其实在以别的用户身份跑东西。这是提权绕过。
正确的解析对提权前缀的处理是:解析不出可信的命令名,就返回 nil。而 nil 被三处调用方统一理解为「非只读、且不给白名单」。
static func segmentCommandToken(_ segment: String) -> String? {
// ...
while i < toks.count, isEnvAssignment(toks[i]) { i += 1 } // 跳过 VAR=val 前缀
let name = commandBasename(raw).lowercased()
if name == "su" || name == "doas" { return nil } // 提权后真实命令名不可信
if name == "sudo" {
i += 1
while i < toks.count, isEnvAssignment(toks[i]) { i += 1 }
if stripGroupChars(toks[i]).hasPrefix("-") { return nil } // sudo 带选项 → 放弃
continue // 否则继续解析被 sudo 的命令
}
// ...
}
回归测试把这个洞钉死了:classify("sudo -u deploy pm2 list", allow: ["-u", "pm2"]) 必须是 .mutating——sudo 后面紧跟 -u(以 - 开头),直接 nil,-u 和 pm2 谁都进不了只读判定。su / doas 无条件返回 nil,因为提权之后真实执行的命令名已经不可信了。
「解析不出来」不是错误状态,是一个安全判定:命令名不可信时,默认走审批,而不是默认放行。
引号里的竖线:一个 bug、两个方向的错
第二类是引号。看这条经典的只读命令:
ps aux | grep -E 'FutuOpenD|FTWebSocket' | grep -v grep
朴素的按 | 拆分会把正则里那个字面的 | 也当管道,切出一个碎片 FTWebSocket' 当命令名——白名单按钮上于是出现「放行 ftwebsocket’」这种鬼东西。同一个根因(不认引号),错在两个方向:既误判(切出本不存在的危险碎片),又把一条教科书级的只读管道多要了一次审批。
修法是一个引号感知的拆分状态机 splitAware:'...' / "..." 里的 | ; && || > < & 全当字面量。但这里有个不能省的安全兜底——破坏模式匹配仍然对整条原始字符串生效。测试断言 echo 'a; rm -rf /' 依然判 .destructive:引号让分隔符失效是为了「正确切分命令名」,但绝不能让引号成为藏 rm -rf 的地方。分词归分词,破坏底线扫的是整串。
换行、包装、路径:破坏底线要穿透一切伪装
还有几个补漏:
- 换行也是分隔符。
ls\nsystemctl restart nginx如果只解析首行ls当只读放行,后面那行就漏判了。所以顶层分隔符集从&& || ;扩到含\n \r; - basename 化,但破坏底线穿透全路径。
/usr/bin/ls按 basenamels匹配白名单;而timeout 5 /bin/systemctl restart nginx配上黑名单systemctl,要能穿透timeout包装和/bin/全路径判成破坏; - 包装前缀透传,但打不穿破坏模式。
nohup/env/exec/setsid/timeout这些透传到被包装的真命令,timeout还要跳过5s/1.5m这类时长参数——但nohup rm -rf /data、timeout 5 rm -rf /data照样判破坏,因为破坏模式匹配的是整条小写化字符串,任何包装都打不穿。
一句话:basename、包装透传是为了「认出白名单命令」,破坏底线扫的却是原始整串——两套逻辑方向相反,缺一不可。
白名单按钮:不是收集 token,是「假设加进去再判一次」
审批弹窗有个「一键批准并加入白名单」。它给哪些 token 出按钮,本身也是安全问题——不能给一个「加了也没用、只是误导用户」的选项。
allowlistCandidates 的设计是验证式的:不是简单收集命令名,而是先假设把这些 token 加进白名单,再重新 classify 一遍,只有真能变成 .readOnly 才给按钮。ps aux > out.txt 带重定向,加什么白名单都不可能只读 → 返回 nil,不给误导选项;cat x | awk … | sort 只返回 awk(cat、sort 是内置只读,被过滤掉)。这把「UI 该不该给放行按钮」和「分类器怎么判」锁死在同一套逻辑里,杜绝二者脱节。
附:sudo 密码只在执行那一刻注入
顺带一提 sudo 提权密码的处理(AGENT-SUDO-01)。密码只在执行那一刻改写进命令,展示给用户、写进历史、发给模型的永远是原始命令:
// -S 从 stdin 读密码;-p '' 抑制密码提示(否则提示会混进 stderr)
return "printf '%s\\n' \(shSingleQuote(password)) | \(env)sudo -S -p '' \(ls.rest)"
密码只在会话内存里、不落盘;非 leading 的 sudo(foo | sudo bar)无法注入,就明确提示而不是卡死。
复盘
- 解析器就是安全边界。当分级判定押在「取命令名」上,解析的每个 bug 都是安全 bug——提权前缀、引号、换行、包装、全路径,任何一个解析错都能让危险命令溜进只读;
- 「解析不出来」的默认必须是「拒绝」。命令名不可信(提权、带选项)时返回 nil,并让所有调用方把 nil 当「非只读、不给白名单」。fail-closed 在这里就是「拿不准命令名就走审批」;
- 分词和破坏底线是两套方向相反的逻辑。引号感知、basename、包装透传是为了正确认出白名单命令;而破坏模式扫的是原始整串——引号、包装、路径都打不穿它。少了任何一套都有洞;
- UI 给不给「放行」按钮,也要走分类器。用「假设加白名单再判一次」来决定候选,而不是单独收集 token——否则 UI 和判定会各说各话,给出加了也没用、甚至误导的选项;
- 同一套解析双栈逐字对齐。Swift 和 Flutter 两端的分类器必须字节级一致——安全逻辑一端修了另一端还有洞,等于没修。
留言