cyberine
Design notes

Hook Matcher: Anchoring vs Exact Match (T563)

Claude Code v2.1.195 changed hook matchers to require an EXACT match for hyphenated identifiers (e.g. code-reviewer, mcpbrave-search). Previously CC's matcher ran an unanchored substring test, so...

Trigger

Claude Code v2.1.195 changed hook matchers to require an EXACT match for hyphenated identifiers (e.g. code-reviewer, mcp__brave-search). Previously CC's matcher ran an unanchored substring test, so a matcher like "search" also fired on subject "mcp__brave-search" — CC treats this as a bug fix, not a behavior change users opted into: CC users write literal tool/agent names, not regex, so silent substring over-match was never intended.

Cyberine's hook engine (src/core/hooks.ts, matcherApplies) has the same shape:

function matcherApplies(matcher: string | undefined, subject: string): boolean {
  if (matcher === undefined || matcher === '') return true;
  try {
    const re = new RegExp(matcher);
    return re.test(subject);
  } catch {
    return false;
  }
}

new RegExp(matcher).test(subject) is unanchored, so a matcher "search" matches subject "research_tool" — the same over-match class CC just fixed.

Decision: keep unanchored regex, document the anchoring convention (Option A)

We did not switch to exact-match (or auto-anchor every matcher). Reasons:

  1. Alternation is a documented, load-bearing feature. Cyberine's matcher field is an explicit regex engine (see the hooks.ts header and USAGE.md's lifecycle-hooks section), and the canonical example is "run_bash|write_file" — a | alternation over exact tool names. CC's matcher has no equivalent regex feature (comma-separated lists cover its multi-name case); Cyberine's does, and switching the default semantics to exact-match would break every config that relies on partial/prefix matching with no way back to substring semantics short of writing .*name.* everywhere.
  2. This isn't actually a bug in Cyberine's engine the way it was in CC's. CC exposed a matcher that looked like a literal-name field but ran regex substring matching under the hood — an implementation leak. Cyberine's matcher is documented as a regex from the start, so unanchored-by-default is the normal, expected semantics of "you give me a regex" (same as grep, without -x). The actual gap was that neither the code header nor USAGE.md called out the anchoring implication for authors used to CC's exact-match mental model.
  3. Smallest correct fix. Auto-wrapping non-alternation matchers in ^(?:...)$ would require distinguishing "the author meant an exact tool name" from "the author meant a partial pattern" — there is no reliable syntactic signal for that (a bare "bash" could mean either). Any heuristic is a silent behavior change for existing configs. Documenting the anchor convention costs nothing and changes no existing behavior.

What changed

  • src/core/hooks.ts — header comment gained a "T563 — matcher stays unanchored regex" decision record; matcherApplies JSDoc calls out the substring-match footgun and the ^...$ anchoring convention. Function exported (was file-private) so it can be smoke-tested directly.
  • USAGE.md (lifecycle hooks section) — the matcher paragraph now tells authors that an unanchored substring match is default regex behavior (not CC's exact-match convention) and to anchor with ^...$ when an exact match is intended; alternation stays ^(?:a|b)$ if the author wants exact alternation instead of substring alternation.
  • scripts/hook-matcher-smoke.ts (npm run selftest:hook-matcher) — regression guard for the anchoring convention and the two fail-open invariants (invalid regex skips silently, empty/ undefined matches everything), so nobody accidentally makes the matcher exact-only later without noticing it breaks alternation.

What did NOT change

  • Config shape (matcher: string) is unchanged.
  • Runtime matching behavior (matcherApplies) is unchanged — still unanchored RegExp.test.
  • Fail-open posture (invalid regex → skip entry, never throw) is unchanged.