把密钥缓存挪到配置根目录:一个"文件夹改名"就能绕过的沙箱漏洞


你给 Hermes 配好了 Bitwarden,让它自己去取数据库密码,省得每次手动粘。为了安全,你还专门给它套了沙箱,把 cache/bws_cache.json 这个路径设成禁止访问——毕竟那是明文的、解出来的密钥缓存。

然后你发现这个“禁止访问”其实一推就倒:把 cache/ 这个文件夹整体改名成 cache2/,那个被屏蔽的文件跟着一起走,在新路径上读它,守卫不认得了。

这不是哪个人的疏忽,而是一个很经典的边界错位:沙箱按路径屏蔽,而路径的上一级目录在 agent 的写权限范围里。 Hermes 在 2026-10-31 把四个凭据缓存文件搬到了配置根目录的最外层,直接把这个绕过路径消掉了。

问题不在“藏得深”,而在“藏在能被改名的目录里”

先把这件事讲清楚,因为它比“某个 bug 修好了”更值得记住。

在 Windows 上,MXC 沙箱(The OpenShell 插件用的那套)通过 deniedPaths 来屏蔽文件。这个机制的前提是:被屏蔽的文件必须直接位于被授予的 home 目录里。只要它在某个子目录里,而那个子目录是 agent 自己可以创建、写入、重命名的,屏蔽就等于没有:

  1. 沙箱说 home/cache/bws_cache.json 不许读。
  2. agent 有 home/cache/ 的写权限,所以它可以把这个目录改名成 home/cache-old/。
  3. 文件跟着目录走,现在是 home/cache-old/bws_cache.json。
  4. 这个路径不在屏蔽名单上,读它,通过。

PR 里写得很直白:这个绕法在 MXC 1.0.0 和 1.0.1 上实测成立。也就是说,之前那套“路径屏蔽”的安全承诺,在“被屏蔽的文件位于可写子目录”这个前提下是站不住的——而四个凭据缓存恰好都符合这个前提。

结论很干脆:光靠屏蔽列表修不了,得让文件根本不在可写子目录里。

四个文件搬到了哪里

存的是什么 旧位置(在可写子目录里) 新位置(配置根目录最外层)
Bitwarden 明文缓存 cache/bws_cache.json bws_cache.json
Bitwarden 加密缓存 cache/bws_cache.enc.json bws_cache.enc.json
1Password 缓存 cache/op_cache.json op_cache.json
Photon sidecar 记录(端口、token、pid) runtime/photon-sidecar.json photon-sidecar.json

文件名不变,只是从子目录提到了 profile home 的最外层。这个位置不能靠“改个文件夹名”来挪动,因为它不是文件夹里的文件,而是 home 本身的直接子项——沙箱屏蔽它时,屏蔽的是那个确切路径。

顺带修掉的一个历史欠账:1Password 的缓存之前没有被任何守卫覆盖。这次不是只搬了位置,还把四个文件统一收进了一份共享清单(hermes_cli/home_data_layout.py 里的 SECRET_CACHE_FILES),所有需要“知道哪些文件是凭据”的地方都从这一份清单取值。

旧副本怎么处理:不读,直接删

升级之后,最自然的问题是:cache/bws_cache.json 那份旧的怎么办?

处理方式是首次使用时直接删除,不读取。原因很直接——那个路径在 agent 可写的目录里,它的内容已经不可信了,读它等于把可能被篡改过的数据请回来。删除是幂等的,删掉之后下次用到 Bitwarden 或 1Password 会重新拉一次。

代价很小,因为四个缓存全都是可再生的:

  • Bitwarden / 1Password 缓存:升级后最多多一次拉取。
  • Photon sidecar 记录:等到网关重启就重建。

有一个诚实的边角:如果某个 profile 从此不再用 Bitwarden 或 1Password,那份旧文件会留在 cache/ 里不被删。它仍然被所有守卫覆盖(因为守卫名单里同时保留了旧路径),只是不再被读取。沙箱插件可以在启动沙箱前主动删掉这些旧路径——OpenShell 插件就会这么做。

跟着一起更新的守卫

搬迁本身只是第一步,真正麻烦的是“所有以为这些文件在旧位置的地方”。这次一起改的地方包括:

  • 凭据路径清单(PROFILE_CREDENTIAL_PATHS):profile 相关的安全检查认这四个新名字。
  • 文件安全层(agent/file_safety):读和写都拒绝,不只是读。
  • 网关的媒体投递拒绝名单:避免通过附件/媒体路径把凭据文件递出去。
  • Dashboard 的 Files API:把四个文件名当作隐藏项,不在文件浏览器里列出来。
  • 备份:备份会跳过这四个顶层名字。这条很重要——凭据缓存不该跟着备份跑到别的机器上。
  • 扁平安装的 .gitignore:四个顶层文件名被忽略,避免它们被误提交进 git。

注意这里的设计取向:不是“加一个特例”,而是“让一个清单成为唯一事实来源”。以后再加一个凭据存储,只要进这份清单,所有守卫自动生效——这比在六个地方各写一遍要可靠得多。

升级后你能看到什么

  • ~/.hermes/bws_cache.json、~/.hermes/op_cache.json 这类文件出现在 home 最外层(多 profile 时是 ~/.hermes/profiles/<名字>/ 下面)。
  • 第一次用到 Bitwarden / 1Password 时,cache/ 下的旧副本消失,新的缓存出现在新位置。
  • 备份包里不再包含这些文件。
  • Dashboard 的文件列表里看不到它们。
  • 沙箱里跑 agent 时,即使它把某个子目录改名,也挪不动这些文件。

如果你有自定义脚本在读 cache/bws_cache.json,现在得改路径了。这是一个会打破兼容性的改动,但它打破的正是那个不安全的路径。

现在能用吗

这个改动在 2026-10-31 合入 main(PR #146715,标签 type/security),并已随 v0.22.6(2026-11-02 发布) 进入正式发行版——从安装包或 hermes update 升到 v0.22.6,缓存就会搬到 profile 根目录。

在它进 release 之前,如果你正在用 MXC 沙箱(尤其是 Windows + OpenShell 插件)跑不信任的代码,别把“我把缓存路径加进 deniedPaths 了”当成安全保证——按上面那个改名手法,它是可以被绕过的。真正稳妥的做法是不要在同一个 profile 里同时放“高价值凭据”和“要跑不信任代码的沙箱”。

想深入看 Hermes 在凭据这块的其他机制,可以读这两篇:密钥加密与 keychain 的取舍,以及日志与消息投递时的密钥脱敏通道。前者管“密钥怎么存”,后者管“密钥怎么不外泄”,这一篇管的是“存密钥的文件放在哪”。