你打印过一次的 API key,不该出现在聊天记录里

晚上十点排查一个 401,你在终端里 cat .env 看了一眼 key 到底配没配对;Hermes 把这条输出读进了会话。二十分钟后你早先设的定时任务把「今天的配置检查结果」发到了团队 Telegram 群 —— 那段文字里,躺着你完整的 API key。你从头到尾只做了一件看起来很安全的事:看了一眼自己的配置文件。
这就是密钥泄露最真实的路径:不是有人闯进来偷,而是同一条文本在「工具输出」「会话记录」「对外消息」之间流转时,某一站没有人负责遮盖。9 月 18 日同一天合入的两个修复,正好补的是这条链上的两站:PR #114839 修工具输出的遮盖规则,PR #114789 修定时任务出口的遮盖策略。下面把两件事讲清楚,顺便说一遍它们没有保护的地方 —— 那部分要靠你自己。
第一站:工具输出里的遮盖规则漏了两种真实存在的密钥形状
Hermes 有一套密钥脱敏器(agent/redact.py),终端输出、文件读取以及下游的会话存储、session_search 都会过它。它靠一张「前缀表」识别各家 key:ghp_、xoxb-、AKIA、hf_、pplx- 等等。默认是开着的,由 security.redact_secrets 控制(关闭时会写启动日志警告)。
问题出在两种不在教科书里的形状:
- 阿里系带点号的
sk-key。sk-sp-…、sk-ws-…这类密钥的身体部分含.,而原来的字符类没把.算进去。结果是匹配在第一个点就停了:前半段被遮住,后半段原样留在输出里 —— 这种「看起来已经脱敏了」的假象比完全不遮更危险。 - 智谱那种没有前缀的
id.secret形状。它没有sk-、ghp_这种可识别前缀,所以前缀表天然匹配不到,整条 key 一路明文。
修法是两处:sk- 的条目现在接受点号分段的身体(sk-sp-…、sk-ws-… 都能完整匹配),并且最后一个点号之后的句子标点不会被吃进去;智谱的密钥单独加了一条刻意贴近厂商形状的正则 —— 32 位小写十六进制的 id,加一个点,再加至少 16 位字母数字的密钥段。之所以不做通用的「高熵点号 token」规则,是为了不误伤内容哈希文件名(<sha>.bundle、<md5>.sqlite3 这类)。测试里专门放了负向控制:40 位裸 sha、sk-short、31 位的截断 id,都必须保持逐字节不变。
效果对照(数字来自 PR 的实测探针):
| 输入 | 修复前 | 修复后 |
|---|---|---|
api_key: sk-sp-...7890.abcdefgh1234567890_XYZ-0987654321 |
尾巴明文泄露 | sk-sp-...4321 |
api_key: 50aaedFAKE1234567890abcdef1234567890abc.ZpSh99 |
原样输出 | 50aaed...Sh99 |
同一文件走 file_read |
«redacted:sk-…».abcdefg… 尾巴还在 |
«redacted:sk-…» / «redacted-secret» |
第三行值得单独说:读到「文件内容」这条路径用的不是遮掩,而是哨兵。因为早些时候一个真实事故(issue #35519)就是这么发生的:agent 读到被截断成「像真的但不完整」的 key,再把它写回 config.yaml,于是存下来的凭据被永久毁成 13 个字符的废值,之后一路 401。哨兵(«redacted-secret»)在语法上不可能被当成可用 token,也就无法被写回去当凭据。顺带一提,性能没被牺牲:2 万字符的身体、1 万个点号、2 千段十六进制这种病态输入,每条都在 3 毫秒内处理完。
第二站:定时任务的出口,现在每一路都强制脱敏
工具输出修好了,还有一条更容易被忽略的路:定时任务的输出是直接往外发的。之前的实现里,脚本作业的 stdout/stderr 在采集处就被清洗过,但 LLM 作业的回复文本没有经过任何扫描就到 _deliver_result,而它下面每一条出口都照原样发出去了。
PR #114789 的修法是把脱敏放在唯一的出口咽喉上,再用一个「失败即拒发」的兜底:
cron/scheduler_delivery.py里新增_redact_cron_payload(),内部调用redact_sensitive_text(text, force=True);force=True的含义:这是出网边界。security.redact_secrets管的是「你自己机器上的日志」,而这一步管的是「离开这台机器的东西」—— 所以它不接受用户配置的关闭,一律强制;- 失败即拒发:如果脱敏器本身抛异常,整段内容被替换为
[REDACTED - redaction failed],绝不「出错了就放原文」。这和脚本输出采集处的姿态保持一致; - 覆盖四条出口:平台消息(
cleaned_delivery_content咽喉)、会话镜像文本(它从原始content派生,不含咽喉的处理)、依赖名生成的标题里的作业名,以及交给另一个 profile 的 bot-chat 回合(重绑content,让延迟投递的记录与重放也带上清洗后的副本); - 失败通知那条路不用单独改,它走的是同一个
_deliver_result咽喉,天然被覆盖。
有一个刻意的例外值得知道:redact_url_credentials 没有对定时任务打开。原因是这类作业经常合法地把 magic link、预签名 URL 投递给你自己的聊天 —— 把带凭据的 URL 整条抹掉会直接弄坏功能。这是 agent/redact.py 在「聊天出口边界」上一直坚持的判断。
它保护不到的地方
这两处修复都很实在,但它们解决的是「Hermes 自己的输出」。以下三件事仍然由你负责:
- 不要把密钥贴进对话。 你主动粘贴的内容同样会进会话历史与数据库;脱敏器会尽力处理已知形状,但「已知形状」永远需要一个不遗漏的名单,而第三方服务的 key 可能长得毫无特征。要排查配置,用
hermes config get <key>看键是否存在、用长度与后四位确认,而不是把值打印出来。 - 定时任务的脚本别 echo 敏感文件。 出口会脱敏,但脚本运行时的日志、CI 记录、shell history 依然可能留下原文。脚本里尽量只输出判断结果(「KEY 已设置」「KEY 缺失」),不输出值。
- 别关脱敏来表达不满。
security.redact_secrets: false只影响本机日志这一路,且会在启动时留下警告;它管不到定时任务出口(那边强制开着)。真在调试脱敏器本身,临时关闭后记得改回来。
想系统了解凭据怎么存、怎么轮换,看密钥加密那篇;用 Telegram 之类平台做通知出口的读者,无人值守审批那篇讲的是另一侧的控制;而定时任务本身的排障,可以看 hermes cron 排障指南和 hermes secrets 命令参考。
什么时候能用上
两个 PR 都在 9 月 18 日合入 main;当前最新 tag 是 v0.21.3(tag 名 v2026.9.14,9 月 14 日发布),所以它们还没进正式 tag。git 安装的用户跑一次更新就有了:
hermes --version
hermes update
Docker 镜像与云端用户要等下个 tag。
小结
把两件事放在一起看,规律很清楚:脱敏是一张必须按时更新的名单,而且要放在唯一的出口上,配上失败即拒发的兜底。 只在前缀表里加名字,就会漏掉阿里系那种带点号的身体;只在某几路出口清洗,就会漏掉会话镜像和作业名这种「顺带带出去」的字段;而一个出错的脱敏器如果退回放行原文,那它保护不了任何东西。今天修的正是这三条缝。