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:
- Alternation is a documented, load-bearing feature. Cyberine's
matcherfield is an explicit regex engine (see thehooks.tsheader 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. - 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. - 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;matcherAppliesJSDoc 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) — thematcherparagraph 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 unanchoredRegExp.test. - Fail-open posture (invalid regex → skip entry, never throw) is unchanged.
CLI Streaming Output — T435 Design Note
Status: shipped — Path 1 landed (T435). See src/orchestrator/Orchestrator.ts:144,374-375. Source cross-checked: src/commands/fleet.ts, src/cli.ts, src/orchestrator/run.ts,...
Bus/Presence/MCP Auth Scope (T584)
apiTokenIsValid (src/store/apiTokens.ts) is wired into exactly one place: the REST authorized() check in src/web/security.ts, which is itself skipped entirely when the web server is bound to...