DSH plugin hook matcher 配 Bash 选不中 bash?大小写敏感排查
如果你从 Claude Code 拷了一份 hooks.json 过来、里面 PreToolUse 写着 "matcher": "Bash",却发现这个 hook 从不开火、工具照常执行,那不是 hook 没挂上,而是 matcher 大小写对不上。 Claude Code 用 PascalCase 工具名(Bash、Read),而 DeepSeek Harness 注册的是小写(bash、read);字面量 matcher 走 pattern.split('|').includes(query) 的大小写敏感精确匹配,对不上就只 continue——静默跳过、不报错、无日志。所以「hooks.json 加载成功」和「hook 真的生效」必须分开验证。
现象:DSH plugin 的 hook 静默不命中,看起来挂上却从不开火
这类故障最难受的地方不是失败,而是它不告诉你自己失败了。 具体表现:
- 配置看起来完全正常:挂上
hooks-claude-code,在hooks.json里写PreToolUse+"matcher": "Bash",hook 逻辑是返回 deny。配置语法没问题、hook 也被加载了——但模型调用 harness 的bash工具时,hook 不运行,工具直接执行(#582)。 - 期望与实际的落差:期望是
Bash能选中bash、deny 生效;实际是零命中。而由于失败是静默的,你甚至不知道是 matcher 没选中、hook 脚本报错、还是 hook 根本没被加载。 - 为什么只有迁移场景会踩:Claude Code 的
hooks.json用 PascalCase(Bash、Read),harness 注册的是小写(bash、read)。本来就按 harness 小写名写 matcher 的配置完全不受影响,所以这是一个迁移陷阱而不是开箱失效——但从 Claude 原样迁过来的PreToolUse安全 hook 会直接静默失效,工具照跑,这个后果必须认真对待(#582)。 - 它和另一类坑同源:这与「allowlist 的 glob 写错 → 工具集静默变空」属于同一类——配置看起来生效,运行时零命中。共同特征就是协议层不把「没匹配上」当成值得报告的事件(#582)。
- 迁移者当时能用的绕过:不用等修复,先把 matcher 改成 harness 实际注册的小写名
"matcher": "bash",或者写小写正则"matcher": "^bash$"。注意必须是带引号的 JSON 字符串(有人第一次贴的示例丢掉了引号,照抄会把hooks.json写坏)。字面量改小写后不用担心误匹配:bash不会命中BashOutput,因为这里做的是精确匹配而不是前缀匹配(#582)。
机制:DSH plugin 的 split('|').includes(query) 大小写敏感与零命中无诊断
一条一行代码的匹配,加一个「不匹配就什么都不说」的默认行为,就构成了这个坑的全部。 逐层拆解:
- 匹配实现只有一行:
packages/hooks/hook-protocol/src/matcher.ts里:
return pattern.split('|').includes(query)
字面量路径把 pattern 按 | 拆开,做逐个精确比较(因此 A|B 这种并列写法是支持的),用的是 Array.prototype.includes——大小写敏感(#582)。
2. 比对的双方分别是 Bash 与 bash:query 是 exec.name,即 harness 注册的工具名 bash;pattern 是从 Claude 配置里读来的 Bash。两个字符串只差首字母大小写,匹配必然失败(#582)。
3. 失败时的处理是 continue:未命中不做任何诊断——不抛错、不写日志、不给警告,直接跳过这个 hook 继续下一个。这是比大小写敏感更根本的问题:它让「matcher 写错了」「hook 脚本本身坏了」「hook 完全没加载」三种情况在运行时表现完全一致,排障只能靠手动实验(#582)。
4. 正则路径是另一条代码路径:如果 matcher 写成 ^Bash$,走的是正则分支,同样大小写敏感,改完字面量之后它依然选不中 bash。补丁刻意不改正则语义——这一点被多位参与者反复强调,因为「以为把正则也顺手放宽了」是很容易犯的误判(#582)。
5. 大小写折叠的作用面可以穷举:有人指出这个改动会影响所有 Claude 字面量 matcher 主体(不只工具名,还包括 session source 等),并要求核对 matchQuery 合同。实测可枚举的 runPoint 主体只有四类:工具名(exec.name,全部小写)、SessionStart 的 source(SessionStartSource = startup / resume / clear / compact)、SubagentStart / SubagentStop(仅 Claude bridge,主体是常量 'general-purpose')、以及 UserPromptSubmit / Stop 的空串哨兵(匹配全部,且两个解析器在验证前就会丢弃这些事件的 matcher 键)。每一个非空主体都是小写,且没有任何主体集合内含仅大小写不同的成对取值,因此大小写不敏感的字面量不会产生歧义——它只会命中它本来就会命中的那一个。这确实是语义拓宽,但当前集合里没有可暴露的碰撞;若将来某个主体集合出现大小写变体对,就需要重新审视,matchesMatcher 的调用点可以带一条注释说明这一点(#582)。
补丁:DSH plugin 字面量大小写不敏感、正则保持严格与零命中诊断
修法分三块:把字面量放宽、别动正则、把「零命中」变成可见。 具体如下:
- 改字面量为大小写不敏感的精确匹配:可 cherry-pick 的分支是
ericcaiwx-star/deepseek-harness @ fix/hook-matcher-claude-literal-case,commit27791eb90d,提交信息为fix(hook-protocol): match Claude literal tool names case-insensitively。边界是——Claude 字面量做大小写不敏感的精确匹配(Bash→bash),Bash仍然不匹配BashOutput,正则路径(^Bash$)保持大小写敏感(#582)。 - 验收方式:跑
pnpm exec vitest run packages/hooks/hook-protocol/tests/matcher.spec.ts应通过,并且断言matchesMatcher('Bash', 'bash', 'claude-code') === true、同时仍不匹配BashOutput与dash。把「不匹配更长/相似名字」写进测试,是为了防止有人把它实现成前缀匹配(#582)。 - 第二个可用的补丁分支:
nokkies/dsh-upstream-patches @ fix/hook-matcher-case-and-timeout-fail-open,边界与上面一致,基于b150a551b8,并且额外带上 #583 / #460 的超时校验。可以直接取用:
git fetch https://github.com/nokkies/dsh-upstream-patches fix/hook-matcher-case-and-timeout-fail-open && git cherry-pick FETCH_HEAD
- 顺序很关键:这条是 #1801 的前置条件:
#1801希望 Claude bridge 在tool_name上发出规范的Write/Edit/Bash(而不是 harness 的小写名),因为第三方 Claude Code hook 消费者按规范词表匹配。两件事看起来独立,其实不然——hooks-codex/src/index.ts里那条注释之所以坚持发原始小写tool_name,正是因为 matcher 测的是exec.name。如果在字面量仍然区分大小写的时候先改 payload,今天所有能用的 matcher 都会失效,包括「改用小写」这个当前唯一的文档化变通方案。先合本条,#1801 才安全;顺序反了就是回归(#582)。 - 明确没修的两处(别假装修了):①
^Bash$仍走正则、仍零命中;② 「配置了却零命中」仍然没有 load 时诊断。第二条被多人点名为「静默失效故事的另一半」——补丁只保证了大小写写法不再是坑,但没有解决「matcher 写错任何别的东西也不会有人告诉你」。这一块被有意拆出去另开,不塞进本条的 cherry-pick 里(#582)。 - 补丁合入前的实操建议:全部用小写最省事——
"matcher": "bash",或"matcher": "^bash$"(记得是带引号的 JSON 字符串)。验证方式必须实测:挂载 hooks 后手动触发一次 bash 工具调用,在 hook 命令里加一行日志(例如echo fired >> hook.log),看到日志才算生效;只确认hooks.json被加载是不够的,因为 matcher 对不上时是静默跳过、不报任何错。等字面量匹配改成大小写不敏感后,大写Bash也能直接用(#582)。 - 给插件作者的经验:无论你在做 DSH插件 还是 DeepSeek插件,写 hook 配置时,matcher 里的工具名都要以 DeepSeek Harness插件 实际注册的名为准(全小写),不要照抄别的生态的大小写习惯;同时给你的 hook 留一条可观测的输出(日志行或写文件),把「静默不命中」这个协议层特性用你自己的可观测性补回来。要在 DSH Plugin Hub 里分发带 hook 的插件时,这类配置检查尤其值得写进插件的自检逻辑(#582)。
DSH plugin 排查注意事项
先记住 matcher 不命中是静默的——「配置已加载」不等于「hook 开火」,唯一可靠的验收方式是让 hook 自己留下一条日志。 八条要点:
- 别只看「配置已加载」:matcher 不命中是静默的,必须实测 hook 真的开火。
- 小写是当前最稳的写法:
"bash"或"^bash$",且必须是带引号的 JSON 字符串。 - 正则不受这个补丁影响:
^Bash$仍然大小写敏感、仍然零命中。 - 不是默认安全洞:本来就写小写的配置不受影响,这是迁移陷阱。
- 精确匹配不是前缀匹配:
bash不会误命中BashOutput、Bash也不会匹配dash。 - 先合这条再改 payload:#1801 若先落地会让现有 matcher 全部失效。
- 零命中诊断仍未提供:matcher 写错别的东西同样静默,需自行加日志验证。
- 大小写折叠的作用面:字面量路径作用于所有 Claude 主体(含 session source),当前主体集合无大小写歧义。

来源:Discussion #582、fix/hook-matcher-claude-literal-case、fix/hook-matcher-case-and-timeout-fail-open。
常见问题
DSH plugin 的 hook matcher 对字面量走大小写敏感的精确匹配,所以 Bash 永远选不中 bash。packages/hooks/hook-protocol/src/matcher.ts 里的 pattern.split('|').includes(query) 拿 Bash 去比 exec.name(即 bash),对不上就只 continue——**没有任何报错或日志**。所以「hooks.json 被加载」和「hook 真的开火」是两件事,只确认前者不够。
这不算 DSH plugin 默认存在的安全漏洞,而是从 Claude Code 迁移时的配置陷阱。matcher 本来就写成小写 bash 的配置**完全不受影响**;只有从 Claude Code 原样拷过来的 PascalCase 写法(Bash、Read)会零命中,所以准确说法是**迁移陷阱**,而不是开箱即失效——但它确实会让「从 Claude 迁来的 PreToolUse 安全 hook」静默不开火、工具照跑,这个后果值得认真对待。
DSH plugin 把字面量改成大小写不敏感后仍然只做精确匹配,所以不会误匹配 BashOutput。补丁把 **Claude 字面量**改成大小写不敏感的**精确匹配**,Bash → bash 成立,但 Bash 仍然**不匹配** BashOutput,也不匹配 dash——因为这里是精确匹配而不是前缀匹配。正则路径(例如 ^Bash$)**刻意保持大小写敏感**,不改变正则语义。
DSH plugin 补丁合入前最快的做法,是把 matcher 改成本 harness 实际注册的小写名 "matcher": "bash"。也可以写小写正则 "matcher": "^bash$"(注意正则路径同样大小写敏感,^Bash$ 现在仍然选不中),并且一定要是**带引号的 JSON 字符串**。改完必须**实测**:挂载 hooks 后手动触发一次 bash 工具调用,在 hook 命令里加一行日志(例如 echo fired >> hook.log),看到日志才算生效。
相关术语
- hook matcher
- hook matcher 是 hooks.json 里决定某个 hook 对哪些工具/事件生效的选择器。字面量走 `split('|').includes(query)` 的精确匹配(可用 `A|B` 列出多个),正则走另一条路径。不匹配时静默跳过,因此「配置错了」和「没配置」在运行时表现完全一样。— https://github.com/deepseek-ai/deepseek-harness/discussions/582
- silent continue
- silent continue 是 matcher 未命中时协议层的处理方式:不做任何诊断、不报错、不记录,直接跳过这个 hook。它让配置层面的错误在运行时不可见,也是「配置看起来生效、实际零命中」这类坑的共同成因。— https://github.com/deepseek-ai/deepseek-harness/discussions/582
- matchQuery subject
- matchQuery subject 是传给 matcher 做比对的查询值。可穷举为:工具名(`exec.name`,全小写)、`SessionStartSource`(`startup`/`resume`/`clear`/`compact`)、常量 `'general-purpose'`,以及匹配全部用的空串哨兵值——都不含仅大小写不同的成对取值。— https://github.com/deepseek-ai/deepseek-harness/discussions/582
来源
- deepseek-harness Discussion #582:Claude hook matcher 大小写敏感,Bash 选不中 bash,安全 hook 静默失效· deepseek-ai(GitHub Discussions)
- 修复分支 ericcaiwx-star/deepseek-harness @ fix/hook-matcher-claude-literal-case(commit 27791eb90d)· GitHub(ericcaiwx-star)
- 社区补丁分支 nokkies/dsh-upstream-patches @ fix/hook-matcher-case-and-timeout-fail-open(基于 b150a551b8,含 #583/#460 超时校验)· GitHub(nokkies)