Agent 把你的数据库写成了乱码:Hermes 现在会拒绝这类写入

周三下午,你让 Hermes 顺手改一个项目里的配置脚本——那个目录下还躺着一个 kanban.db 或者你自己应用的 app.db。它照做了,跑得很快,你也没多想。第二天你启动应用,数据库打不开:database disk image is malformed。你没删过它,没改过它,甚至没打开过它。写坏它的是你请来的那位助手——它的 write_file 把数据库的日志文件当成了一个普通文本文件。
这不是假设。Hermes 自己的仓库里就发生过这件事:一个团队的 kanban.db 从 55 万字节变成了一堆 U+FFFD 替换字符,SQLite 直接判定“数据库镜像已损坏”,无法恢复。2026 年 9 月 17 日合并的 PR #114220 修掉了这个洞,本文讲清楚它到底怎么坏、修在哪里、以及在你手上的版本里有没有。
事故是怎么发生的:文本工具写进了二进制文件
write_file 和 patch 是给文本用的工具。它们读文件时会解码成字符串,写回时再编码回去。对纯文本这没问题;对二进制文件,这个来回就是灾难——读的时候非 UTF-8 的字节会被替换成 U+FFFD("�"),写回去的时候那些字节就永久丢失了。
真正让人后背发凉的地方在于:Hermes 并没有“决定”要写数据库。它想写的是旁边的某个文件,或者它按你的要求整理目录时顺手覆盖了一个它以为的“文本文件”。问题出在判定环节——工具是怎么决定“这个文件是二进制、我不该碰”的?
tools/binary_extensions.py
之前的判定逻辑:只看路径的最后一个后缀
app.db -> .db -> 在二进制名单里 -> 拒绝 ✓
state.db-wal -> .db-wal -> 不在名单里 -> 当成文本 ✗
.db 本身早就被覆盖保护拦住了,但 SQLite 的侧车文件没有。这些侧车文件是什么?
x.db-wal:WAL(预写日志)模式的“草稿本”。新的写入先落在这里,之后才合并回主库。WAL 被写坏,等于把还没合并的账本撕了。x.db-shm:WAL 的共享内存索引,进程之间靠它协调读写位置。x.db-journal:回滚日志,非 WAL 模式下用来撤销未完成的事务。
用大白话说:主库是一本装订好的账,WAL 是桌上那叠还没誊进去的流水。你拿涂改液往流水上刷,第二天整本账都对不上。所以那个团队的库不是“少了几行”,而是直接被判定为损坏。
修复做了什么:一处判定,所有读写路径共用
PR #114220 的思路很干净:不修补症状,而是把“这是 SQLite 侧车文件”变成一个全仓库共用的判定,让读和写用同一把尺子。
具体改动如下:
- 侧车文件识别搬进
tools/binary_extensions.py:新增一个_lower_suffix小助手和is_sqlite_sidecar判定,read_file的读保护、file_tools.py以及tools/file_operations.py里五个沙箱读点都走同一份逻辑——读守卫、写守卫、沙箱读点不再各说各话。 - 侧车文件即使当前不存在也拒绝写入:数据库做过 checkpoint 之后,磁盘上可能一个侧车文件都没有,但下一次写入就会创建它。只检查“文件是否存在”的守卫会正好在这个窗口里漏过去。
- 覆盖已有的二进制文件仍然被拒;创建新的
.db文件不再被禁止——仓库里有测试用例就叫*.db,一刀切禁掉创建会误伤(这一点与原提交者的策略不同,是上游评审后放开的)。 read_file不再返回侧车文件的“乱码文本”,而是明确按二进制处理。
上游在真实数据库上做的对照实测很直白:
| 动作 | 修复前 | 修复后 |
|---|---|---|
write_file 写 state.db-wal(写入 10 字节) |
写入成功 → SQLite 报 disk I/O error |
拒绝写入,文件字节不变,integrity_check 通过 |
write_file 写 new notes.db(新建) |
允许 | 仍然允许 |
read_file 读侧车文件 |
返回有损文本 | 按二进制处理 |
配套测试 21 个用例(覆盖“侧车存在 / 侧车不存在 / 主库本身 / patch 工具”四种组合),全部通过。这次修复还有一个有意思的工程背景:它是对社区 PR #114091 的“收编”(salvage)——原作者开了 PR,上游评审后没有直接合并,而是把作者的提交 cherry-pick 过来、保留了署名,再补上更广的覆盖面。这类收编在 Hermes 仓库里非常常见,也是它能在一天内修掉几十个 bug 的原因之一。
你的版本里有这个修复吗
要诚实地说:没有。这个提交 2026 年 9 月 17 日才合并进 main,而目前最新的正式发布是 v0.21.3(tag v2026.9.14,9 月 14 日),修复排在它之后。先看看你自己的版本:
hermes --version
hermes update
在修复进入你手上的版本之前,有三条务实的做法:
- 别把活着的数据库放进 agent 的工作目录。数据库文件、备份目录、
*-wal侧车文件都挪出项目树,或者至少移出你允许 agent 自由写入的路径。 - 要改库就让 agent 用
sqlite3命令行,而不是文本工具。常见的安全操作比如.backup(热备份)和VACUUM INTO 'copy.db'(另存一份紧凑副本),都是数据库自己在管字节,不存在“文本往返”问题。 - 习惯性地备份。SQLite 库被这种写坏之后几乎没有恢复余地——原报告里那个 55 万字节的
kanban.db就是彻底不可恢复,因为损失发生在“读-替换-写回”这一步,坏的不是索引而是数据本身。
这条守卫背后的通用教训
把这件事抽象一层,它对所有 agent 工具链都成立:一个文本工具不可能无损往返二进制内容,所以“接受这个目标”本身就是缺陷,而不是“写入时出了差错”。修补的关键不是在写入阶段做更聪明的转换,而是在入口处就拒绝这类目标。Hermes 仓库里有四个读路径早就做了二进制嗅探,唯独写路径没有——漏洞往往就长在这种“某个路径忘了对齐”的缝隙里。
顺带一提,PR 里还留了一个未做的后续项:对没有扩展名的数据库文件做魔数嗅探(SQLite 文件头是 SQLite format 3\0)。也就是说 backup(不带任何后缀)这种文件暂时还是按文本处理。如果你的目录里真有这种命名习惯,那么第 1 条建议(挪出工作目录)就是唯一的保险。
从更宽的视角看,这次修复和站内之前聊过的几个话题是同一类问题:数据库文件的并发写入、WAL 句柄被别的进程占着、hermes doctor --fix 在不该动库的时候动库——它们都指向同一件事,agent 的能力越强,它身边那些“不该碰的东西”就越需要机器可读的保护,而不是靠你记得提醒它。工具层的拒绝写保护,比提示词里写一句“不要改数据库文件”可靠得多。
小结
- 病灶:
binary_extensions.py只看最后一个后缀,.db-wal这种侧车文件被当成普通文本,被读成乱码再写回,直接把库写坏。 - 修复(PR #114220,2026-09-17 合并):统一的
is_sqlite_sidecar判定,读写与沙箱路径共用;侧车文件无论存在与否都拒写;二进制文件覆盖被拒、新建允许。 - 状态:在
main上,尚未进入任何已发布版本(最新 v0.21.3)。 - 你现在就能做的:挪走活库、用
sqlite3CLI 改库、勤备份。
相关阅读:站点上的 v0.21.3 发布说明讲的是同一类“数据库写入安全”问题;如果你遇到的是库已经损坏、需要抢救,可以看 state.db 损坏修复指南;想搞清楚 hermes doctor 对 WAL 的建议什么时候会从“有用”变成“有害”,看 doctor 的 WAL 诚实警告;而 Docker 与跨机 state.db 的 WAL 注意事项则是同一主题在容器环境下的版本。