你只是打开看了看那个社区技能,它却跑了一段 shell

你从社区 hub 装了一个技能,扫了一眼安装成功的提示,然后某天在会话里把它打开,只想看看它到底写了什么。麻烦就在这里:打开这个动作本身在过去可能已经执行了技能正文里的内联 shell 片段。你没运行任何命令,命令却跑完了。
问题不是“扫描器漏掉了什么”,而是谁算社区技能这个判断没做准。这次有四处信任边界被同时收紧,顺带把一屋子误报和两个“永远装不上”的老 bug 一起修了。
内联 shell 是什么,什么时候真的会跑
Hermes 的技能正文允许写一种内联 shell 片段——把 !`cmd` 写在 SKILL.md 里。当你打开或加载这个技能时,这段命令会通过 bash -c 展开,把输出注入到技能正文里,让技能可以带实时数据(比如当前时间、当前分支)。
这个能力默认是关的:
hermes config get skills.inline_shell # 默认 False
打开它的方式是在 config.yaml 里:
skills:
inline_shell: true
inline_shell_timeout: 10 # 每个片段最多 10 秒
关键在于:inline_shell: true 是一次武装所有技能的开关。所以真正决定“这段命令会不会跑”的,不是扫描器给了什么结论,而是运行时怎么判断这个技能是不是来自社区。这次修的就是这个判断。
旧判断的三处漏洞
原来的 _is_community_hub_skill 判断社区来源,靠三个假设,而三个都太乐观了:
- 只匹配“目录正好等于”安装记录里的路径。 一个社区 bundle 里嵌套的技能(比如
agents/inner/SKILL.md)就不算社区技能; - 只读当前 profile 的 hub 锁。 另一个 profile 通过
skills.external_dirs引到这份技能时,它在“别人的”锁里,判断就落空; - 锁读不出来就当“不是社区”。 这是最糟的一条——fail open:一个被截断或格式不对的
.hub/lock.json会让整棵树上的技能重新变成“可信”。
现在的行为:
| 场景 | 旧行为 | 现在 |
|---|---|---|
| 社区 bundle 里嵌套的技能 | 会展开 | 不展开 |
另一个 profile 通过 external_dirs 引到它 |
会展开 | 不展开 |
技能树的 .hub/lock.json 被截断或形状不对 |
当作非社区,会展开 | 整棵树 fail closed,都不展开 |
| 根本没有锁文件(这台机器没装过 hub 技能) | 不展开 | 不变 |
判断逻辑改成了两条硬规则:匹配“安装目录或它下面的任何目录”(用解析后的真实路径),并且同时读当前 profile 的锁和这份技能所在技能树的锁——技能树就是往上找第一个叫 skills/ 且带 .hub/lock.json 的目录。换句话说,“谁在看这个技能”不再影响它的来源判定。
代价是需要在文档里说清楚的:当一棵树的锁损坏期间,同一棵树里你自己手写的技能也不会展开——锁坏了就无法区分哪些目录是社区安装,所以整棵树先一律不展开,修好锁即恢复。这个取舍写进了创建技能的文档。
扫描器的另一面:skills-guard-v9 修的是“误报”
前面那篇讲扫描器盲点,这一批修的是反方向的问题:拦得太狠。
先看数据。14 天内新客户端里,scan_blocked 是 hub 技能安装失败的第一大原因(hermes-index 39 次事件 / 8 个安装日,clawhub 19/15,skills-sh 5/4,url 6/4)。重点在于:这些都是真实失败——安装被拒了。原因不是技能危险,而是模式匹配的精度不够。在一个 89 条的索引随机样本里,13 次拦截里大约 10 次是规则误报。
根因之一很典型:整张规则表是用 re.IGNORECASE 编译的,于是找 AWS 密钥的 AKIA[0-9A-Z]{16} 会命中占位符 AKIAxxxxxxxxxxxxxxxx。同样地,rm -rf /、base64 -d |、setuid、HTML 注释注入这些规则,会在 Dockerfile 的缓存清理、| jq、正常行文和排版注释上开火。
修正之后,在 421 个真实索引技能的随机样本上,被拦安装从 83 降到 68——解封 33 个,0 个新增被拦。真正构造的攻击仍然全部被拦。
具体改了这几类:
- 密钥形状改成大小写敏感:
AKIA、ghp_/github_pat_、sk-、sk-ant-、glpat-、BEGIN PRIVATE KEY,全部不再匹配全小写的占位符; rm -rf /规则:豁免包管理器的缓存清理目录(/var/lib/apt/lists、/var/cache/apt|apk|yum|dnf),但逐个检查每个操作数——反而抓到了以前漏掉的rm -rf --no-preserve-root /;base64 -d管道:只有当解码后的内容在后续任一阶段到达解释器(sh/bash/python/perl/node/eval/xargs/env/sudo/iex/pwsh),或直接喂给| tar(打进去一个扫描器看不到的包),才报警;| jq、| grep这类数据消费者不再报警;- HTML 注释注入:必须带注入语义(“忽略之前的指令”、系统提示词、隐藏指令之类),普通注释不再触发;
- setuid:只有真实调用才是 critical(
set*id(、cap_set[ug]id、chmod u+s/g=s/八进制 2xxx–7xxx),光出现这个词降为提示——顺带抓到了以前漏掉的chmod 4755; curl … | sudo -E bash现在会命中curl_pipe_shell(旧规则只认| bash);- 扫描器版本推进到
skills-guard-v9(插件扫描器plugin-guard-v10),缓存的判定随之作废并重扫。
有个细节值得一提:这次改动自己引入了一个回归,第二天就修了——规则开始检查 rm 的每一个操作数之后,行尾的 // 注释被当成了根路径,于是 rm -rf node_modules dist // clean 被判成 critical。修法是:第一个操作数之后的裸 // 不再当成 /,但 rm -rf //、rm -rf //etc 仍然是 critical。检查了 633 个真实技能的缓存样本,判定、安装决定、findings 集合完全一致。
索引里那 1900 行“永远装不上”的技能
如果你从索引里挑到一个装不上的技能,失败往往长这样:stale_index,而且客户端在失败前已经花了大约 40 次 GitHub API 调用。
原因很朴素:skills.sh 有大约 20,000 行,其中约 1,920–1,985 行没有解析出 resolved_github_id——也就是没有可下载的位置。批处理构建索引时,把三种本质不同的情况当成了同一种:仓库 404、树读到了但没有匹配的技能、树被截断,全部原样发出。它的模糊匹配还只去掉 - 不去掉 _,而且从来没考虑“一个仓库里只有一个根级技能”这种布局,可客户端明明会装。
现在构建器会区分 ok / truncated / gone(仓库 404)/ error 四种状态;匹配时两边都做 [-_] 归一化(所以 pdb-database 能匹配上 pdb_database);只有根目录或 skills/、.agents/skills/、.claude/skills/ 这类通用目录里有 SKILL.md 的仓库也能正确解析。仓库已消失的行、以及在完整树里找不到对应技能的行,直接丢弃;被截断的树(posthog/posthog 有 5.8 万个条目)改用逐候选探测,每仓库最多 10 行;error(5xx、超时、鉴权失败)保持原样发出——瞬时故障绝不会导致误删。构建日志现在会多一句:Resolved N/M paths, dropped D uninstallable (G repo gone, R not in repo)。
抽样 75 行的对比很直观:旧版发出 75 行(其中 27 行未解析),新版发出 57 行、未解析 0 行、删除 18 行(3 个仓库已消失,15 个仓库里没有这个技能)。
顺手修好的两个“必然失败”的安装
内置技能恢复不了。 你删了技能但目录里还剩文件,或者上一次复制中断,都会留下一个没有 SKILL.md 的目录。之后每次重试都是 [Errno 17] File exists。另一种情形是分类目录是符号链接:加载器跟着符号链接能正常用,但恢复流程的 rglob 不会进去,于是碰到底线保护报 Unsafe install path。现在规范位置的 SKILL.md 就算“已存在”,复制改用 dirs_exist_ok,会绕开残留文件补齐而保留它们。另外,恢复失败的记录以前会从 bundle=None 退回成 source=hub,现在正确记成 source=bundled。
匿名用户的 API 配额。 装技能时,hub 里技能目录下的每个文件都要花一次 GitHub Contents API 调用。匿名配额是每小时 60 次,所以一个 58 个文件以上的技能永远装不上(14 天内 hermes-index 的 rate_limited 有 26 次事件 / 9 个安装日)。现在文件优先从 raw.githubusercontent.com/<repo>/<sha>/<path> 读取——同样的不可变字节,而且不计入 API 配额;只有 raw 失败(私有仓库、raw 服务故障)才回退到 Contents API。
你现在可以做什么
- 确认内联 shell 的状态:
hermes config get skills.inline_shell。不需要就保持关闭——这是最省事的一道防线。 - 别忘了扫描器缓存的判定已经作废:扫描器版本变了,下次访问会重扫。以前被误拦的技能现在可以重装。
- 如果你是共享部署:确认各 profile 的
.hub/lock.json是可解析的,别让一棵损坏的锁把整棵树的技能功能一起关掉。 - 别自己 hack 绕过拦截:
hermes skills install --force可以无视被拦的判定,但它存在的意义是给“你确认过内容”的场景用的,不是给“扫描器又误报了真烦”用的。
小结
这一批改动的共同点是把“信任”从静态判断换成了按来源和血缘判断:技能来自哪里、它的安装记录在哪个锁里、谁在看它,都比“它现在这个目录长得像不像社区技能”更可靠。判断不清时一律 fail closed——包括那个有点麻烦但正确的副作用:损坏的锁会让整棵技能树暂时不能展开内联 shell,直到你把锁修好。
想补齐背景,先看我们那篇讲扫描器三个盲点的文章,再看 hermes skills 命令参考。如果你在装第三方技能,这两篇也值得一读:插件隔离与宿主边界、便携插件与 MCP 的信任门禁。想从可信来源挑,可以看插件目录那篇的思路。