DSH plugin hook matcher: Bash does not match bash
If you copied a hooks.json from Claude Code, its PreToolUse entry says "matcher": "Bash", and yet the hook never fires while the tool runs anyway — that is not a hook that failed to mount, it is a matcher that fails on case. Claude Code uses PascalCase tool names (Bash, Read), while DeepSeek Harness registers lowercase ones (bash, read); the literal matcher path is a case-sensitive exact match via pattern.split('|').includes(query), and a miss just continues — silently skipped, no error, no log. So "hooks.json loaded successfully" and "the hook actually works" must be verified separately.
Symptom: a DSH plugin hook looks mounted but silently never fires
The hardest part of this failure is not that it fails, but that it never tells you it failed. Concretely:
- The configuration looks perfectly normal: you mount
hooks-claude-code, write aPreToolUseentry inhooks.jsonwith"matcher": "Bash", and the hook body returns deny. The syntax is fine and the hook is loaded — but when the model calls the harnessbashtool, the hook does not run and the tool executes directly (#582). - The gap between expectation and reality: you expect
Bashto selectbashand the deny to take effect; instead you get zero hits. And because the failure is silent, you cannot even tell whether the matcher missed, the hook script errored, or the hook was never loaded at all. - Why only migrations hit it: Claude Code's
hooks.jsonuses PascalCase (Bash,Read) while the harness registers lowercase (bash,read). Configurations that already write matchers using the harness's lowercase names are completely unaffected, so this is a migration trap rather than an out-of-the-box failure — but aPreToolUsesafety hook migrated verbatim from Claude silently stops firing while tools keep running, and that consequence must be taken seriously (#582). - It shares a root with another class of trap: this is the same family as "a mistyped allowlist glob silently empties the tool set" — the config looks effective, but runtime gets zero hits. The shared trait is that the protocol layer does not treat "nothing matched" as an event worth reporting (#582).
- The workaround available to migrators right now: without waiting for the fix, first change the matcher to the lowercase name the harness actually registers,
"matcher": "bash", or write a lowercase regex"matcher": "^bash$". Note it must be a quoted JSON string (someone's first example dropped the quotes, and copying it breakshooks.json). Lowercasing the literal carries no over-matching risk:bashwill not hitBashOutput, because this is an exact match, not a prefix match (#582).
Mechanism: in a DSH plugin, split('|').includes(query) is case-sensitive with no zero-hit diagnostic
One line of matching code plus a "say nothing on a miss" default is the entire bug. Layer by layer:
- The matching implementation is one line: in
packages/hooks/hook-protocol/src/matcher.ts:
return pattern.split('|').includes(query)
The literal path splits the pattern on | and does a per-element exact comparison (which is why the A|B alternation form is supported), using Array.prototype.includes — which is case-sensitive (#582).
2. The two sides being compared are Bash and bash: query is exec.name, the tool name the harness registers (bash); pattern is the Bash read from the Claude config. The two strings differ only in the case of the first letter, so the match necessarily fails (#582).
3. On a miss the handling is continue: no diagnostic at all — no throw, no log, no warning, just skip this hook and move on. This is the more fundamental problem than case sensitivity: it makes "the matcher is mistyped", "the hook script itself is broken", and "the hook was never loaded" behave identically at runtime, so troubleshooting can only proceed by manual experiment (#582).
4. The regex path is a separate code path: if the matcher is written ^Bash$, it takes the regex branch, which is also case-sensitive, so after the literal fix it still fails to select bash. The patch deliberately leaves regex semantics alone — several participants stressed this repeatedly, because "assuming the regex was relaxed too" is an easy misreading (#582).
5. The blast radius of case folding can be enumerated: someone pointed out the change touches all Claude literal matcher subjects (not just tool names but session sources too) and asked for the matchQuery contract to be reviewed. The runPoint subjects that can actually be enumerated are just four: tool names (exec.name, all lowercase), SessionStart's source (SessionStartSource = startup / resume / clear / compact), SubagentStart / SubagentStop (Claude bridge only, subject is the constant 'general-purpose'), and the empty-string sentinel for UserPromptSubmit / Stop (matches everything, and both parsers discard the matcher key for those events before validation). Every non-empty subject is lowercase, and no subject set contains paired values differing only in case, so a case-insensitive literal introduces no ambiguity — it only ever hits the one it would have hit anyway. It is a genuine semantic broadening, but nothing in today's set can collide; if a future subject set gains case variants, this needs revisiting, and a comment at the matchesMatcher call site can record that (#582).
Patch: DSH plugin case-insensitive literals, strict regex, and the zero-hit diagnostic
The fix has three parts: relax the literal, leave regex alone, and make "zero hits" visible. Specifically:
- Make the literal a case-insensitive exact match: the cherry-pickable branch is
ericcaiwx-star/deepseek-harness @ fix/hook-matcher-claude-literal-case, commit27791eb90d, messagefix(hook-protocol): match Claude literal tool names case-insensitively. The boundary is — the Claude literal becomes a case-insensitive exact match (Bash→bash),Bashstill does not matchBashOutput, and the regex path (^Bash$) stays case-sensitive (#582). - How to verify:
pnpm exec vitest run packages/hooks/hook-protocol/tests/matcher.spec.tsshould pass, and should assertmatchesMatcher('Bash', 'bash', 'claude-code') === truewhile still not matchingBashOutputordash. Putting "does not match a longer/similar name" into the test is what stops someone implementing it as a prefix match (#582). - A second usable patch branch:
nokkies/dsh-upstream-patches @ fix/hook-matcher-case-and-timeout-fail-open, with the same boundary, based onb150a551b8, additionally carrying the #583 / #460 timeout validation. Take it directly:
git fetch https://github.com/nokkies/dsh-upstream-patches fix/hook-matcher-case-and-timeout-fail-open && git cherry-pick FETCH_HEAD
- Order matters: this one is a prerequisite for #1801:
#1801wants the Claude bridge to emit canonicalWrite/Edit/Bashontool_name(rather than the harness's lowercase names), because third-party Claude Code hook consumers match against the canonical vocabulary. The two look independent but are not — the comment inhooks-codex/src/index.tsinsists on emitting the original lowercasetool_nameprecisely because the matcher testsexec.name. If the payload changed first while literals were still case-sensitive, every matcher that works today would break, including the "switch to lowercase" workaround that is currently the only documented mitigation. Land this one first, then #1801 is safe; reversed order is a regression (#582). - Two things explicitly not fixed (do not pretend they were): ①
^Bash$still goes through regex and still zero-hits; ② "configured but zero-hit" still has no load-time diagnostic. The second was named by several people as "the other half of the silent-failure story" — the patch only ensures the case spelling is no longer a trap, but does not solve "a matcher mistyped in any other way also tells nobody". That part is deliberately split out into its own item rather than stuffed into this cherry-pick (#582). - Practical advice before the patch lands: lowercase everything, it is simplest —
"matcher": "bash", or"matcher": "^bash$"(remember it is a quoted JSON string). Verification must be empirical: after mounting hooks, trigger one bash tool call manually with a log line in the hook command (for exampleecho fired >> hook.log), and only seeing that line counts as working; confirminghooks.jsonwas loaded is not enough, because a matcher miss is a silent skip that reports nothing. Once literal matching is case-insensitive, uppercaseBashwill work directly too (#582). - A lesson for plugin authors: when writing hook configuration, take tool names in the matcher from the harness's actually registered names (all lowercase) rather than copying the casing conventions of another ecosystem; and give your hook an observable output (a log line or a written file) so you restore with your own observability what the protocol layer's "silent non-match" takes away. When distributing a hook-bearing plugin through DSH Plugin Hub, this kind of configuration check is especially worth putting into the plugin's own self-check logic (#582).
DSH plugin troubleshooting notes
Remember first that a matcher miss is silent — "the config was loaded" is not the same as "the hook fires", and the only reliable acceptance test is making the hook leave a log line of its own. Eight points to keep in mind when wiring a DeepSeek Harness plugin hook:
- Do not stop at "config is loaded": matcher misses are silent, so you must empirically confirm the hook actually fires.
- Lowercase is the safest form today:
"bash"or"^bash$", and it must be a quoted JSON string. - Regex is untouched by this patch:
^Bash$remains case-sensitive and still zero-hits. - Not a default security hole: configurations already written lowercase are unaffected; this is a migration trap.
- Exact match is not prefix match:
bashwill not wrongly hitBashOutput, andBashwill not matchdash. - Land this before changing the payload: if #1801 goes first, every existing matcher breaks.
- The zero-hit diagnostic is still missing: a matcher mistyped in any other way is equally silent, so add your own log to verify.
- Blast radius of case folding: the literal path applies to all Claude subjects (session sources included), and the current subject set has no case ambiguity.

Sources: Discussion #582, fix/hook-matcher-claude-literal-case, fix/hook-matcher-case-and-timeout-fail-open.
FAQ
In a DSH plugin the literal hook matcher is case-sensitive, so Bash never selects bash. In packages/hooks/hook-protocol/src/matcher.ts, pattern.split('|').includes(query) compares Bash against exec.name (which is bash), and on a miss it just continues — **no error and no log**. So "hooks.json was loaded" and "the hook actually fired" are two different things, and confirming only the first is not enough.
This is not a DSH plugin security hole that exists by default; it is a migration trap. Configurations whose matcher already uses lowercase bash are **completely unaffected**; only PascalCase carried over verbatim from Claude Code (Bash, Read) zero-hits, so the accurate framing is a **migration trap**, not an out-of-the-box failure — but it does make a PreToolUse safety hook migrated from Claude silently stop firing while tools keep running, and that consequence deserves to be taken seriously.
In a DSH plugin, case-insensitive matching is still an exact match, so it does not wrongly match BashOutput. The patch makes the **Claude literal** a case-insensitive **exact match**, so Bash → bash holds, but Bash still does **not** match BashOutput, nor dash — because this is an exact match, not a prefix match. The regex path (for example ^Bash$) is **deliberately kept case-sensitive**, so regex semantics do not change.
The fastest DSH plugin fix before the patch lands is to change the matcher to the lowercase name this harness actually registers: "matcher": "bash". You can also write a lowercase regex "matcher": "^bash$" (note the regex path is equally case-sensitive, so ^Bash$ still zero-hits today), and it must be a **quoted JSON string**. Then you must **actually test it**: after mounting hooks, trigger one bash tool call manually with a log line in the hook command (for example echo fired >> hook.log) — seeing that line is the only proof it works.
Related Terms
- hook matcher
- The selector in hooks.json that decides which tools/events a hook applies to. Literals go through the `split('|').includes(query)` exact match (you can list several with `A|B`), while regexes take a separate path. A miss is skipped silently, so "misconfigured" and "not configured" look identical at runtime.— https://github.com/deepseek-ai/deepseek-harness/discussions/582
- silent continue
- How the protocol layer handles a matcher miss: no diagnostic, no error, no log, just skip this hook. It makes configuration errors invisible at runtime, and it is the shared cause of the "config looks effective but zero-hits" class of traps.— https://github.com/deepseek-ai/deepseek-harness/discussions/582
- matchQuery subject
- The query value handed to the matcher for comparison. It can be enumerated as: tool names (`exec.name`, all lowercase), `SessionStartSource` (`startup`/`resume`/`clear`/`compact`), the constant `'general-purpose'`, and the empty-string sentinel used to match everything — none of which contains paired values differing only in case.— https://github.com/deepseek-ai/deepseek-harness/discussions/582
Sources
- deepseek-harness Discussion #582: Claude hook matcher is case-sensitive, Bash does not match bash, safety hooks silently stop working· deepseek-ai (GitHub Discussions)
- Fix branch ericcaiwx-star/deepseek-harness @ fix/hook-matcher-claude-literal-case (commit 27791eb90d)· GitHub (ericcaiwx-star)
- Community patch branch nokkies/dsh-upstream-patches @ fix/hook-matcher-case-and-timeout-fail-open (based on b150a551b8, includes #583/#460 timeout validation)· GitHub (nokkies)