开源自托管项目 Hermes Agent 怎么兜住技能自动创建
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
技能自动创建的难点从来不是「让模型写出一份 SKILL.md」,而是写完之后:这份文件谁审、渲染时会不会在你机器上执行东西、三个月后没人用了谁来收拾。 这里说的是 NousResearch 的开源常驻 Agent 项目 hermes-agent(MIT 许可证,LICENSE 署名 Nous Research),不是 Nous Research 那个同名的开源模型系列,也不是任何同名商标或库。
站内已经写过技能这件事的另外几个切面:技能文件该怎么写 讲的是写法与契约本身,技能的组织法 讲的是另一个项目怎么切分与检索,三个项目的技能机制横向对照 讲的是设计取向差异。本篇不重复这些,只钉在一个具体仓库上:它把「敢开自动创建」这件事落成了哪几个文件、哪几个默认值,以及你把某一块关掉之后会发生什么。
一、先把三块摆在一起看
自动创建技能这条链路,在这个仓库里被拆成了互不知情的三段:技能内容在被送进上下文前要过一遍渲染,技能文件在落盘前可能要过一遍静态体检,技能落盘之后要被持续记账。三段之上还有一个把多个技能打包成一条斜杠命令的组合层。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 技能预处理 | 把 SKILL.md 里的模板变量替换成真实值,可选地把内联 shell 片段换成命令输出 | agent/skill_preprocessing.py | 每次一个技能被加载进上下文 |
| 技能守卫 | 对技能目录做结构检查、正则威胁匹配、隐形字符检测,结合来源信任级别给出装/不装的判定 | tools/skills_guard.py | 从注册源装技能时;配置打开后,agent 自己写技能时 |
| 使用统计 | 在旁挂的 JSON 里记 use/view/patch 计数与时间戳,并维护 active/stale/archived 生命周期 | tools/skill_usage.py | 技能被看、被用、被改时;后台维护任务跑的时候 |
| 技能包 | 用一个 YAML 把若干技能聚成一条斜杠命令,一次性全部加载 | agent/skill_bundles.py | 你想让一句 /xxx 同时带上三四个技能 |
| 写入入口 | 承接 create/patch/edit/write_file/remove_file/delete,负责调用守卫并在被拦时回滚 | tools/skill_manager_tool.py | agent 调 skill_manage 的每一次 |
| 后台维护 | 按不活跃时长走状态迁移,可选地跑一次模型合并 | agent/curator.py | 会话启动时判定该不该跑 |
仓库自带的技能目录本身也能说明规模量级:skills/ 下 14 个分类目录共 70 份 SKILL.md,optional-skills/ 下 111 份,plugins/ 18 个顶层插件目录,optional-mcps/ 6 个,tests/ 里 2499 个 test_ 开头的测试文件。技能不是点缀,是这个项目的主要扩展面——这也是它必须给自动创建配兜底的原因。
二、技能预处理:技能文件是渲染出来的,不是读出来的
preprocess_skill_content 只做两件事,而且都受配置控制。
第一件是模板变量替换。正则只认两个 token:${HERMES_SKILL_DIR} 和 ${HERMES_SESSION_ID},分别替成技能目录的绝对路径和当前会话 id。这里有个刻意的取舍:解析不出具体值的 token 原样留在文本里,不清空也不报错——注释写明是为了让技能作者自己看见它没生效。它不是通用模板引擎,没有条件、没有循环,就这两个名字。
对你的意义很直接:技能作者可以在 SKILL.md 里写「运行 ${HERMES_SKILL_DIR}/scripts/xxx」,agent 拿到的就是一条能直接交给终端的命令,不用自己拼路径,也省掉一次回头查看技能目录的往返。这个开关是 skills.template_vars,默认开。
第二件是内联 shell 展开。语法是 !`cmd`,正则是非贪婪且不跨行的——反引号里不能有换行。命中的每一段都用 bash -c 执行,工作目录设成技能所在目录(所以技能里的相对路径按作者预期工作),stdin 直接指向 DEVNULL。这一块的错误处理值得单独看:超时返回一个 [inline-shell timeout after Ns: cmd] 标记,找不到 bash 返回 [inline-shell error: bash not found],其它异常也只返回 [inline-shell error: ...],一律不抛。输出还有 4000 字符的上限,超了截断并加 ...[truncated]。设计意图写在注释里:一个坏片段不能毁掉整条技能消息,一条跑飞的命令不能把上下文顶满。
这块的开关是 skills.inline_shell,默认关,skills.inline_shell_timeout 默认 10 秒。默认关的理由在 hermes_cli/config_defaults.py 的注释里写得很直白:技能作者提供的内容会在宿主机上无审批地执行,只对你信任的技能来源打开。这句话请当真——它意味着在这个开关打开的前提下,「加载一个技能」和「在你机器上跑一段别人写的命令」是同一件事。
配置的读取是宽松的:load_skills_config 走只读配置加载,任何异常都吞掉并返回空字典走默认值。同一套渲染流程在 agent/skill_commands.py 和 tools/skills_tool.py 各有一份调用点,斜杠命令加载和工具加载走的是同样的行为。
三、技能守卫:外来技能进门前的静态体检
scan_skill 做三类检查:结构、正则、隐形字符。
结构检查看的是「这像不像一份技能」:文件数超过 50、单个文件超过 256KB、目录总量超过 1MB 都会出 finding;.exe/.dll/.so 这类二进制扩展名直接判 critical;不是 .sh/.py/.rb 这类脚本却带着可执行位会被标出来;符号链接必须解析到技能目录之内,指向外面就是 symlink_escape,critical。
正则那部分是一张长表,按类别铺开:机密外泄、提示注入、破坏性命令、持久化、反弹 shell 与隧道、混淆、进程执行、路径穿越、挖矿、供应链、提权、凭据泄露。判决规则很朴素——出现任一 critical 判 dangerous,出现任一 high 判 caution,只有 medium/low 时仍然算 safe,注释里说明这两级是「信息性的,不阻塞」。
这张表更值得学的是它写进去的反误报留白,几处都带注释解释为什么要开洞:cat 读密钥文件算外泄,但 cat > file 这种输出重定向是在写自己的配置,方向正相反,所以用前瞻排除掉;裸访问 os.environ 可疑,但 os.environ.get("某个普通配置名") 只是读本地变量、什么都没往外发,也被前瞻放过,而名字里带 KEY/TOKEN/SECRET 的仍然会被另一条规则抓住;allowed-tools: 是技能规范要求的 frontmatter,每份合规技能都会声明,所以它被降到 low、只作审计线索,不再影响判决。一个只会误报的扫描器,最后一定会被整体关掉——这些注释是在为「它还开着」买保险。
隐形字符检测单独列了一组:零宽空格、词连接符、各种双向文本控制码(LTR/RTL 嵌入、覆盖、隔离),命中就是 high 的注入类 finding,每行只报一次。这类字符是用来把指令藏在人眼看不见的地方的,正则表匹配不到。
判定的另一半是信任级别。_resolve_trust_level 把来源映射成 builtin / trusted / community / agent-created 四档:内置的永不扫描;TRUSTED_REPOS 是硬编码的四个仓库(openai/skills、anthropics/skills、huggingface/skills、NVIDIA/skills),匹配要求精确相等或是该仓库下的技能路径,只共享前缀的兄弟仓库不给蹭;official 这个来源标识归到 builtin,注释解释了原因——官方可选技能必须靠来源出处识别,不能靠用户可控的 GitHub 标识符冒充。剩下全是 community。
策略矩阵 INSTALL_POLICY 把「判决 × 信任」压成一个动作:community 来源只要出 caution 就 block;trusted 来源 caution 放过、dangerous 拦住;--force 能覆盖普通的 block,但覆盖不了 community/trusted 的 dangerous,should_allow_install 会明说这一点。agent-created 这一档单独设计成 dangerous 时返回「ask」——函数返回 None 而不是布尔,落到写入入口就是给 agent 一条工具错误,让它去掉被标记的内容重试。
写入入口的回滚也做了:tools/skill_manager_tool.py 里 create 被拦就把整个技能目录删掉,patch/edit 被拦就把原文写回,写文件被拦就恢复原内容或删掉新建的文件。扫描不通过时磁盘上不留半成品。
然后是这套设计里最诚实的一句话:这道 agent 自写技能的扫描,默认是关的。skills.guard_agent_created 默认 False,注释给的理由是——agent 本来就能通过终端工具执行同样的代码路径且没有任何门禁,所以这层扫描只增加摩擦(会因为散文里提到危险关键词而拦下正常技能),换不来实质安全。从注册源装的外来技能则永远扫描,不受这个开关影响。换句话说,项目自己把这一层定位成「腰带加背带」,不是安全边界。真正的边界在别处:终端权限本身,以及 skills.write_approval 那道人工审批(打开后写入会被暂存,走 /skills pending、/skills diff、/skills approve 或 /skills reject)。关于权限边界该怎么划,可以对照 最小权限怎么设计 那篇的框架来读这套配置。
扫描结果还有存证机制。scan_skill_cached 用 full_content_hash(对相对路径和文件字节做规范化 sha256)加上扫描器版本、来源标识、来源 URL 组成缓存键,写进 .scan-cache 目录。内容改一个字节哈希就变,缓存自然失效——它缓存的是「这一份确切内容的扫描结论」,不是「这个技能名的扫描结论」。技能还可以自带 .skillignore(或兼容用的 .clawhubignore)把开发产物、计划文档排除出扫描范围,但 SKILL.md 本身永远不可被排除。
四、使用统计:让「谁该退休」变成可算的
这块的载体是一个旁挂文件:技能目录下的 .usage.json,按技能名做 key。注释写明为什么不写进 SKILL.md 的 frontmatter——把运维遥测和作者内容分开,避免给捆绑技能和从注册源装来的技能制造冲突压力。写入是 tempfile 加 os.replace、带 fsync 的原子替换;跨进程用文件锁串行化读改写,Unix 走 fcntl,Windows 走 msvcrt。所有计数递增都是尽力而为:失败只记 debug 日志,绝不把底层工具调用带崩。
记的东西很少:use / view / patch 三组计数与对应时间戳,加上 created_at、state、pinned、archived_at。latest_activity_at 只在那三个活动时间戳里取最新——created_at 被刻意排除在「活动」之外,这样「从来没被用过」才仍然可辨识,生命周期代码要用创建时间当锚点时自己去取。
一个容易忽略的分层:遥测对所有技能都记,内置的、从注册源装的,一视同仁,注释里明确说「使用统计是纯观察,与是否被治理无关」;但生命周期字段(状态、pin、治理标记、同步标记)的写入都带资格校验,不会往一个治不了的技能上写「已归档」这种没意义的状态。
资格规则值得抄:从注册源装的技能永不治理,因为它有外部上游 owner;配置在 skills.external_dirs 里的外部技能目录对维护任务只读;PROTECTED_BUILTIN_SKILLS 目前只有一个成员 plan,注释解释了为什么要有这份名单——它背着 /plan 这条斜杠命令流程,被静默归档之后命令会变成「未知命令」,用户完全收不到信号。这类「载荷型内置项」在任何自动清理系统里都得单列。
接着是我认为整个模块最值得读的一段:created_by 这个字段的命名坑,仓库里用一整段注释在讲。字段名读起来像出处,实际被当作「是否允许自动维护介入」的策略开关消费。注释把两个问题拆开:出处是历史事实,对于字段出现之前写下的记录根本无法追回;而治理与否是用户随时可以改的策略决定。字段名保留是因为它已经在每个用户的磁盘上了,改名会让旧记录失联。所以代码里另外提供了一个语义化别名 is_curator_managed,让调用点读起来就是它真正在问的问题。这是那种只有维护过线上数据格式的人才写得出的注释。
由此就有了一类「合格但没人管」的技能:前台的 skill_manage(create) 故意不打治理标记(用户要的技能归用户),只有后台自我改进分支创建的才会 mark_agent_created。于是 list_unmanaged_skill_names 和 unmanaged_report 把这个盲区显式报出来,状态命令展示它的数量,hermes curator adopt 让用户手动把某个技能交出去。注释里那句话很硬:出处是声明,永远不是推断;patch 次数多只能证明有人在维护它,不能证明是 agent 写的——agent 替用户改用户自己的技能是家常事。
状态机是 active / stale / archived 三态,pinned 与之正交。curator.stale_after_days 默认 30 天,curator.archive_after_days 默认 90 天。真正保命的是三个防误杀:第一,第一次见到一个有资格却没有记录的技能,先给它写一条基线记录把不活跃时钟锚在当下,这一轮不做判定,避免把「没有记录」当成「远古未用」;第二,use_count 为 0 的技能有宽限地板,只要它比 stale 窗口还新就完全不动,注释说得好——从没被用过是证据缺失,不是过期的证据,可能只是触发条件还没出现;第三,被任何定时任务引用的技能等同于 pinned,因为调度器只在任务真正触发时才记一次使用,那些比归档窗口触发得更稀疏的任务、暂停中的任务、排在很远的一次性任务,否则会被从脚底下抽掉。
归档是把目录移进 .archive/,从不删除。内置技能被归档还得往 .curator_suppressed 写一行,否则下一次更新时的重新播种会把它原样拷回来;恢复时这条抑制记录会被清掉。恢复的候选匹配也做了收口:只认技能名本身,或者「名字 + 短横 + 14 位数字」这种归档冲突时才会加的 UTC 时间戳后缀。注释解释了为什么不能用简单的前缀匹配——恢复 git 会把归档里的 git-helpers 一起拖出来并改名成 git,把那个兄弟技能的唯一副本毁掉。
最后两个默认值:模型合并那一步(curator.consolidate)默认关,关着的时候一次维护只做确定性的不活跃清理,不花辅助模型的钱;跑前快照默认开,会把技能目录打成 tar.gz 存档,出事能整体回退。
五、缺一块分别会发生什么
把三块分开假设一下,这套设计的必要性就清楚了。
只有预处理,没有守卫。 技能能渲染、能带动态上下文,但任何来源的技能文件都直接落盘生效。这时候如果内联 shell 也开着,一份带隐形字符或藏在 HTML 注释里的指令的技能,就同时拿到了「进上下文」和「在宿主机执行」两件事。这不是理论风险——守卫里那些注入类正则和隐形字符表,就是照着这类形状写的。
只有预处理和守卫,没有统计。 自动创建会让技能目录单向膨胀。技能多了本身就会稀释检索质量(模型要在更多描述里挑对那一个),更麻烦的是没有任何依据回答「哪个能动、哪个不能动」:内置技能、从注册源装的技能、用户自己写的、agent 自己写的,混在一个目录里,任何自动清理都变成掷骰子。上面那些资格规则和保护名单,全都建立在「有一份按技能名索引的记账」这个前提上。
只有守卫和统计,没有预处理。 这一块缺失不出安全事故,出的是能力上限被压低:技能只能是静态文本,作者没法引用自己带的脚本,agent 得自己做路径拼接、多跑一次查看动作。仓库里那句「无需路径计算、无需额外往返」就是在说这个。
三块的关系不是层层加固,而是各管一段:预处理管「技能能表达什么」,守卫管「什么能落盘」,统计管「落盘之后归谁、什么时候退场」。缺任何一段,另外两段都还能跑,只是自动创建这件事就不敢开到默认。
六、边界与代价
守卫是正则静态分析,不是沙箱。 它挡形状,不挡语义。换个变量名、把命令拆两行、用等价写法,都能绕过;反过来,一篇正经讲安全审计的技能文档会因为提到关键词而被判 dangerous。项目自己给 agent 自写路径的默认值是关,就是承认了这个代价对不上收益。
统计是观察,不是理解。 判定 stale 只看时间戳,完全不看技能内容的质量、正确性、是否已被更好的技能取代。真正需要判断力的那一步(合并重叠技能)交给模型,而且默认关闭。
归档不删除,磁盘只增不减。 抑制列表还引入了一个隐性状态:某个内置技能被裁掉之后,你执行更新会发现它没回来,得去看那个抑制文件才知道为什么。
created_by 的字段名与语义不一致,是明知故犯的技术债。 读代码要绕一下,注释虽然解释得很清楚,但新人第一眼一定会误读。
这个项目常驻在你机器上、开终端执行命令、连你的聊天软件账号、往磁盘写文件、访问外部服务。 这些技能机制不管其中任何一项的风险。内联 shell 打开时执行的是真命令,不是模拟;守卫不通过时回滚的只是技能文件,不是它已经产生的副作用。把它装在一台能碰生产的机器上,技能扫描给不了你任何安慰。
它明确不管的还有这些: 技能内容对不对、模型会不会在该用的时候用错技能、两个技能给出矛盾指令时听谁的。技能包那一层只规定了一条冲突规则——斜杠名相同时技能包胜过单个技能,注释说这是有意为之,因为用户既然把一个包命名成某个名字,就是想让那条命令指向自己的包。剩下的冲突全靠技能作者自己在文本里协调。想把「用错技能」这类问题量化,只能另外搭评测,这套代码里没有任何一处在做这件事。
七、上手与避坑清单
把内联 shell 当成便利功能打开。 会踩是因为它看起来只是「让技能能拿到今天日期和 git 状态」,而配置注释里那句「技能作者的内容在宿主机上无审批执行」不在你打开开关时的视线里。避法:只在你自己写的技能目录上打开,装任何外来技能之前先关掉;如果一定要长期开着,就把外部技能目录和自写技能目录分开管理。
以为 agent 自己写的技能会被扫描。 会踩是因为仓库里确实有一整套守卫代码,很容易假设它默认生效。避法:想要就显式打开对应配置项,并接受它会误伤提到危险关键词的正常技能;更有效的是同时打开写入审批,让技能写入变成暂存待审,用查看待审、看差异、批准或驳回这几步走完。
以为技能一写完就被自动维护接管。 会踩是因为「agent 创建的技能」听起来天然属于自动治理范围,但前台创建刻意不打标记。避法:定期看状态命令里「未被管理」的数量,对确实希望自动维护的技能用 adopt 显式交管;不要靠 patch 次数去猜。
以为自动维护只动 agent 写的技能。 会踩是因为文档里反复强调「只管 agent 创建的」,但内置技能剪枝的开关默认是开的。避法:不希望内置技能被动就把那个开关设为 false(它豁免全部内置技能);只想保住个别几个就用 pin。
以为归档能自己回来。 会踩是因为内置技能平时每次更新都会被重新播种,直觉上「删了也会回来」。实际上归档一个内置技能会写抑制记录,更新时会被跳过。避法:用恢复命令而不是手动移动目录,恢复流程会顺带清掉抑制记录。
试图用强制安装绕过 dangerous 判定。 会踩是因为强制标志确实能覆盖一部分拦截,很容易以为它万能。避法:先读扫描报告里指出的具体文件和行号——它给的是文件名、行号和命中的那行文本,逐条看要么改内容要么换来源,别在这里硬顶。
给技能包起了和某个技能一样的名字。 会踩是因为两套斜杠命令共用一个命名空间,而冲突时技能包静默胜出,你会发现那条命令行为变了却没有任何报错。避法:技能包用明显不同的命名前缀;技能包 YAML 里的名字缺失时会拿文件名当兜底,所以文件名也别和技能撞。
指望这套机制替你判断技能内容质量。 会踩是因为「有扫描、有统计」容易被读成「有质量把关」。避法:把内容质量放到别的环节——真人评审、把技能当代码走审批、或者用评测集回归。这套代码从头到尾没有一行在判断技能写得好不好。
八、收尾
把这三块合起来,它的取向其实相当保守:能力开关(内联 shell)默认关,自写技能扫描默认关,模型合并默认关,删除永不发生,出处只认声明不认推断,载荷型内置项单独进保护名单。真正默认打开的只有两件不太可能出错的事——模板变量替换和跑前快照。敢开自动创建,靠的不是哪一块特别强,而是把「能表达什么」「什么能落盘」「谁来收拾」拆成三段各自有默认值、各自能单独关掉的机制。
给你三条落地前的自检:一,你打算装的技能来源,落在四档信任级别的哪一档,对应的判定动作你接受吗;二,你机器上这个进程能碰到的东西(终端、聊天账号、磁盘、外网),有没有超出你愿意让一份自动生成的技能文件影响的范围;三,三个月后你打算怎么知道哪些技能从来没被用过——如果答案是「到时候再看」,那统计这一块你就等于没开。
接下来读哪个文件:想搞清楚渲染时机,从 agent/skill_commands.py 和 tools/skills_tool.py 里调用预处理的那两处入手;想搞清楚拦截与回滚的完整路径,读 tools/skill_manager_tool.py;想搞清楚状态迁移的全部条件,读 agent/curator.py 里做自动迁移的那个函数;所有默认值集中在 hermes_cli/config_defaults.py 的对应两段配置里,注释比文档细。技能被自动改动之后怎么和日常运维对齐,可以顺着 Agent 日常运维 那篇的检查项排一遍。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的学习链路拆解 和 开源自托管 Agent 项目 Hermes Agent 的技能溯源与同步。