开源自托管 Agent 项目 Hermes Agent 写盘前的四道关

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

很多人把「Agent 的写入防护」当成一个开关来抄,而在 NousResearch 的这个开源自托管 Agent 项目 hermes-agent 里,它其实是四段互不调用的逻辑,各自解决完全不同的失败模式——你只抄其中一段,剩下三种事故照样会发生。(同名的还有 Nous Research 的开源模型系列,以及若干同名商标与库,本文说的是那个常驻在你机器上、会开终端、会往磁盘写文件的 Agent 项目,MIT 许可证,LICENSE 署名 Nous Research。)

这四段分别是:审批一个跨会话持久化写入要不要先经你同意;校验一个路径解析之后有没有跑出允许的根目录;判断一个具体文件到底能不能读能不能写;以及在模型自己转不出来的时候按住它的手。它们写在四个文件里,彼此几乎不知道对方存在。

站内已有的 pi 的编辑安全设计 讲的是另一个项目的做法,Agent 改动边界约定Agent 最小权限设计 讲的是不挑框架的通用方法论;本文只做一件事——把这个具体项目的四段实现摊开,让你能拿着自己的代码逐段对照。

一、先把四段逻辑的分工认清

组成部分它负责什么仓库位置你什么时候会碰到它
写入批准门记忆与技能这两个跨会话存储的写入,要不要先经你确认;不确认就落盘到待审队列tools/write_approval.py打开对应配置项之后,agent 想改记忆文件或某份 SKILL.md 时
路径包含校验路径解析后是否还在允许的根目录内、原始字符串里有没有 ..tools/path_security.py往技能目录写附属文件、给定时任务指定脚本名时
文件读写名单哪些绝对路径与目录前缀永不许写、哪些文件不许读,外加跨 profile 与沙箱镜像的误写识别agent/file_safety.py每一次文件写入、每一次文件读取
工具调用护栏同一个调用反复失败、只读调用反复返回同一结果、单轮内搜索与派发次数的上限agent/tool_guardrails.py模型卡在同一个错误上不停重试时

看这张表最容易漏掉的一点:第一行和第三行的触发时机完全不同。审批门只盯记忆和技能两个持久化存储,你让 agent 改项目里一个普通源文件,审批门根本不参与,管这件事的是第三行的名单和环境变量。

二、写入批准门:拦的不是删库,是后台线程替你做的决定

tools/write_approval.py 的模块注释把动机写得很直白:这个 Agent 有两类会跨会话存活的写入——小体量的声明式记忆条目,和体量可能到几十 KB 的技能文件;而这两类写入有两个来源,一个是你在场的正常对话轮,另一个是一轮结束之后自动跑的自我改进复盘分叉,注释里点名说后者正是用户抱怨「它自己脑补了错误结论」的源头。

配置上它只有一个布尔键,键名就叫 write_approval,按子系统分别设置,默认 false,也就是门是关的、写入照旧直接落盘。打开之后判定走 evaluate_gate,返回一个三态结果:allow(照常写)、blocked(你明确按了拒绝)、stage(不写,先落到待审队列)。

决策矩阵值得逐条看,因为它把「什么时候能内联问你」这件事想得比较细:

  • 门关着 → 直接放行。
  • 门开着、目标是记忆、并且当前线程上注册了交互式审批回调 → 内联弹给你看全文,条目本来就小,一个聊天气泡装得下。
  • 门开着、目标是记忆、但你是从网关会话、脚本、定时任务或后台线程进来的 → 不问,落盘到待审队列。
  • 门开着、目标是技能 → 无论来源一律落盘到待审队列,注释给的理由是一份技能文件太大,没法在对话流中间用眼睛扫一遍。

待审记录是文件形态的,按子系统分目录、按随机短 id 命名的 JSON,先写临时文件再原子替换。好处是进程重启不丢,并且命令行、网关、网页面板都能读同一份队列。命令行侧对应 /memory pending/skills pending 这类子命令,技能还多一条查看完整差异的出口。

技能的一行摘要由 skill_gist 生成,纯启发式、不调模型:新建和整体重写就从内容的 frontmatter 里正则抓 description: 那一行再附上体积;局部替换就数替换前后各多少行;写附属文件、删附属文件、删技能各有各的一句话。完整差异另有一个函数,用 difflib 拿当前磁盘内容和新内容比。这个分层很实用——气泡里给你一行,不够判断再去看差异。

两个细节能看出作者踩过什么坑。一是内联询问复用终端工具那套「危险命令审批」的按线程回调,但代码直接调回调、不走那层包装函数,注释写明原因:那层包装会把回调抛出的异常吞成「拒绝」,还有一个 input() 回退容易在交互框架下死锁。二是这个门永远只延迟写入、不静默拒绝——配置里没有「全部阻断」这一档,blocked 只在你亲手拒绝时才出现;要彻底停掉一个子系统得用它自己的启用开关。落盘失败时代码只记日志、写入就此丢掉,注释说这是审批门该有的失败方向:宁可丢,也不能悄悄提交。

对你的意义:给自己的 Agent 加审批之前,先回答一个问题——审批通道自己坏掉的时候,默认往哪边倒。这个项目选的是「丢弃」而不是「放行」,而且把三种来源的可审批性写成了显式矩阵,不是靠一个全局布尔糊过去。审批交互本身的取舍可以对照 人在环中的介入点设计 一起看。

三、路径校验与文件名单:一个管别越界,一个管别碰

tools/path_security.py 短得出乎意料,两个函数:一个把路径和根目录都 resolve() 之后做包含判断,捕获异常时返回「路径逃出允许目录」的错误字符串;另一个把字符串切成路径分量,看里面有没有 ..。模块注释说这是从若干工具实现里抽出来的重复模式,点名了技能管理、技能相关工具、技能中心、定时任务工具和凭据文件几处。

调用现场是这样用的。技能写附属文件时,先查有没有 .. 分量,再查第一层目录是否落在允许集合里——允许的只有 referencestemplatesscriptsassets 四个,SKILL.md 是个例外因为它在技能根目录,但代码把遍历检查放在例外判断之前,注释专门说明了这个顺序的用意:例外分支永远不可能被带 .. 的路径走到。定时任务那边是另一种用法:脚本名拼进 Hermes 的脚本目录之后,再验证解析结果是否仍在这个目录内。

agent/file_safety.py 是完全不同的思路——名单。拒写的精确路径包括 SSH 目录下的几个关键文件、.netrc.pgpass.npmrc.pypirc.git-credentials/etc/sudoers/etc/passwd/etc/shadow,以及 Hermes 自己的环境文件和凭据存储。拒写的目录前缀包括 SSH、AWS、GnuPG、kube、Docker、Azure 的配置目录,ghgcloud 的配置目录,以及 /etc/sudoers.d/etc/systemd。这里有个专门加宽过的判断:profile 模式下,除了当前 profile 的环境文件与凭据文件,顶层那份也一并拒写,理由是覆盖顶层会把凭据泄漏给所有继承它的 profile。

同一个文件里还挡住了会话状态:状态数据库和会话目录不许被通用文件工具改写,理由是让 agent 随手改会话记录等于允许它伪造对话历史,并且会让恢复与压缩状态失效。这个视角容易被忽略——大家防的都是凭据,很少有人把「自己的运行状态」也列进去。

读侧是另一个函数,挡三类:技能中心的内部缓存文件(注释明说这是可能被当成提示注入载体的东西,让你改用列表和查看工具);Hermes 的凭据与令牌存储;以及任何位置的项目级环境文件——.env.env.local.env.development.env.production.env.test.env.staging.envrc 这一族按文件名匹配,错误信息里建议你改读 .env.example。写侧还有一个环境变量开关,设置之后在黑名单之上再叠一层白名单:只有列出的根目录前缀之内能写,多个目录用操作系统自己的路径分隔符隔开。注意这是叠加不是替换——代码里先查黑名单再查白名单,所以把安全根指向家目录并不会因此放开 SSH 私钥,文档也专门点了这一条。文档里还明确说白名单这道拒绝是硬拒绝,不进入危险命令审批流程、没有提示可以覆盖。

这个文件最值得抄走的不是名单,是它的自我定位。模块注释反复写着同一句话:这不是安全边界。终端工具以同一个操作系统用户身份运行,agent 完全可以直接 cat 出被拒绝的文件。它承认自己的价值只有两条——给尊重工具拒绝的模型返回一个明确错误,让大多数模型就此停手而不是转去用 shell;以及在日志里留下一条比通用 cat 更显眼的审计痕迹。相关的用户可见说明也被要求写成「可能有帮助」而不是「能挡住攻击者」。真要隔离靠的是容器和操作系统权限,这块可以看 Agent 工作区隔离

文件末尾还有两个「软护栏」,来源都是真实事故。一个是跨 profile:profile 就是 Hermes 根目录下的独立家目录,各有自己的技能、插件、定时任务、记忆四块受保护区域,代码把这四个名字列成一个常量,注释说往这里加一项就等于扩展了护栏、不用改别的代码。它分辨路径属于哪个 profile、当前跑在哪个 profile,不一致就返回一段警告文本,并明确告诉调用方不要静默放行。注释里记了触发这条规则的事故:一次安全 profile 的会话同时改了自己和默认 profile 的技能,当时没人意识到第二个路径属于另一个 profile。

另一个是沙箱镜像。非本地终端后端会把一个宿主目录挂成容器里的家目录,磁盘上于是出现一条形如「沙箱目录 / 后端 / 任务 / home / .hermes / …」的路径。模型如果猜权威状态在这条路径下,写入就落在宿主进程永远不读的那份副本上——它报告成功、你看不到变化、磁盘上悄悄多出一份分叉。检测是纯路径形状匹配,不依赖任何解析器成功。但它只覆盖宿主视角带完整前缀的路径;容器内部挂载已经把前缀吃掉,agent 看到的就是普通家目录路径,这种情况得由调用方把当前镜像前缀传进来才能识别。这两个软护栏共用同一个绕过参数,语义是「我知道我在干什么」。

四、工具调用护栏:防的是模型自己转不出来

agent/tool_guardrails.py 开头就声明这个控制器是无副作用的:它只记录本轮的调用观测、返回决策,至于决策变成一段警告文字、一个合成的工具结果、还是把这一轮直接停掉,由运行时决定。

它先给工具分了两类:幂等只读的一类是读文件、搜文件、联网搜索、会话检索、浏览器快照这些;会改状态的一类是终端、执行代码、写文件、局部替换、待办、记忆、技能管理、浏览器交互、发消息、定时任务、派发任务、进程管理这些。判定幂等时先看是否落在会改状态那一类里,落在里面就直接不算幂等——两个集合万一有交集,安全的一侧优先。

计数分三种:完全相同的调用连续失败(身份由工具名加参数规范化 JSON 的哈希构成);同一个工具本轮失败了多少次(不管参数);幂等工具返回了多少次同一个结果哈希。决策有四档:放行、警告、阻断、停轮。

配置结构值得单独说,因为它把「提醒」和「刹车」分成了两套独立门槛。配置节名为 tool_loop_guardrails,下面两个布尔键分管两种行为:warnings_enabled 默认开着,hard_stop_enabled 默认关着。阈值不是一个数,而是两组同构的键——warn_after 一组、hard_stop_after 一组,每组下面都是同样三个名字:exact_failuresame_tool_failureidempotent_no_progress,正好对应上面那三种计数。警告那一组的门槛设得比硬停低,于是同一次打转会先收到提示、再撞上刹车。具体默认值在仓库的示例配置文件里给全了,跟着代码走,抄之前自己看一眼当前值,不要背。这种「同一套判据两级门槛」的写法比单阈值好用:你可以把警告调得很敏感当探针用,硬停留在保守位置,两者互不干扰。

配置文件里的说明也写清了取舍:软警告默认开,只在重复失败或没有进展的工具结果后面追加一段引导,工具照样执行;硬停是给自主运行和定时会话准备的选项,那种场景下停下来比把迭代预算烧完更好。

除了这套检测,还有一层单轮上限,专门管两个容易失控的工具:联网搜索和派发任务。这层上限跟硬停开关无关,无论硬停是否打开都生效,计数器每轮重置,设成 0 就是不限。派发任务的计数按批量列表的长度算而不是按调用次数算——一次调用派出十个就记十个,注释说这样上限才反映真实的派发量。

两个工程细节顺手可以学。一是调用签名对外暴露的元数据只有工具名和参数哈希,原始参数值不出去,日志里不会因为护栏又漏一份参数。二是哈希编码用了容错模式,注释说从网页抓回来的工具结果可能带未配对的代理项字符,严格编码会抛异常并把整个对话循环带崩——哈希只需要确定性字节,不需要合法编码。护栏本身在防模型打转,而这行代码在防护栏把会话搞崩,两层都得有人管。

五、边界与代价:这套设计明确不管的事

  • 三个软护栏都不是安全边界。 读拒绝、跨 profile、沙箱镜像三处的注释都写着同一句:终端工具以同一用户身份运行,可以绕过。不能拿它当合规依据。
  • 白名单一旦启用就没有覆盖通道。 文档专门写了最常见的误用:把安全根指向项目目录,然后期望 agent 还能改 Hermes 自己的定时任务、技能和脚本——那些路径在根之外,每次写都会失败。
  • 审批门的覆盖面很窄。 它只管记忆和技能,不管终端命令,也不管普通文件写。指望打开它就等于「agent 动任何东西都要问我」,会失望。
  • 审批体验取决于你从哪个界面进来。 内联询问要求当前线程上注册了命令行的审批回调,网关、脚本、定时任务、后台线程一律改成落盘待审;落盘再失败,写入就丢了。
  • 工具护栏是按轮计数、不做语义判断。 每轮清零,跨轮反复出现的同一种失败它不管;它看的只有失败标记、参数哈希、结果哈希,模型把参数改一个字符就是全新签名。它治的是「原地打转」,不是「换个说法继续打转」。
  • 名单永远滞后。 文件安全那几个清单里多处注释挂着「这个文件是某次改动引入的、当时没加进这个守卫」。你抄走一份名单,抄到的是某个时间点的快照。

六、上手与避坑清单

1. 打开审批门后 agent 说「已保存」,其实没保存。 会踩是因为它拿到的返回是「已暂存待审」,模型顺着复述成了完成。避法:把两个子系统的待审队列都纳入日常查看,它们是分开的目录、分开的列表,只看一个会漏。

2. 指望后台自我改进的写入弹窗问你。 会踩是因为直觉上「有人在电脑前就该问」,但后台来源永远走暂存——守护线程没法阻塞在一个交互提示上。避法:把这类写入当成异步收件箱来处理,定期批量过一遍,而不是等它找你。

3. 等着看技能写入的完整差异。 会踩是因为记忆写入给的是全文,容易以为技能也一样。技能一律暂存,聊天里只给一行启发式摘要。避法:先用摘要判断要不要细看,需要细看再去调差异出口,别指望对话流里塞得下。

4. 设了写入白名单之后 Hermes 自己的状态改不动。 会踩是因为设置时只想到「限制在项目里」,忘了 agent 的技能、定时任务、脚本都在别处。避法:需要两边都写就把两个前缀一起列上,并且注意分隔符跟随操作系统,跨平台的部署脚本容易在这里错。

5. 多 profile 下改到了别人的技能。 会踩是因为两条路径长得几乎一样,只差中间一段;软护栏会给警告,但绕过参数就在同一次工具调用里、模型自己就能填。避法:不要把这个绕过参数写进常规提示词,让它只在你明确要求时出现。

6. 容器后端下写进了镜像副本。 会踩是因为宿主视角和容器视角看到的路径不同,形状检测只覆盖前者。避法:容器后端下要动 agent 的权威状态,走宿主侧的专用工具,别让模型用通用文件写去猜路径。

7. 以为只读工具反复返回同样结果会被拦。 会踩是因为默认配置下这只是警告。避法:无人值守和定时会话再考虑打开硬停,交互式会话保留警告——你在场的时候,停轮的代价通常比多转两圈更大。

8. 把「读不了 .env」当成 bug 去修。 会踩是因为拒绝按文件名匹配,任何目录下的这一族文件都拦,跟是不是 Hermes 自己的目录无关。避法:读 .env.example 看结构;真要看值,你自己看,别让 agent 把它读进上下文。

收束

这四段逻辑的共同点是都很小、都能单独读完,而且都在注释里老实交代了自己不管什么。这比一个笼统的「安全模块」有用——你能准确知道少了哪一段会出哪种事故。

拿它对照自己的项目,问五个问题就够了:跨会话持久化的写入有没有一个能被人看见、能被人否掉的入口;路径校验是不是每个工具各写一遍(那就一定有一处漏了);拒绝清单里除了凭据,有没有把自己的运行状态也放进去;软护栏的绕过参数会不会被模型顺手填上;模型原地打转时,是谁在计数。

接着往下读,建议直接看 tools/file_tools.py:跨 profile、宿主侧沙箱镜像、容器侧沙箱镜像三个警告在那里被串在同一处,读拒绝的调用点也在同一个文件里——那是这几段逻辑在一次真实写入里汇合的地方。测试目录下也有对应的用例文件,想确认某条名单的实际行为,看测试比看注释快。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 怎么把网络出口关小开源自托管项目 Hermes Agent 的 MoA 多模型合议

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。