/** * Hook-matcher verification — the cross-referencing moat applied to the MATCHER * string inside a hook registration. A hook fires only when its `matcher` selects * the tool name the harness emits; a typo or an unmatchable pattern silently * prevents the hook from ever running — exactly the FALSE CONFIDENCE failure the * compiled-hooks design exists to eliminate (research/hook-pain-points.md). * * ## The matching semantics this detector models (MEASURED, not assumed) * * A matcher is NOT a literal tool name — it is a pattern. Measured against the * real `claude` CLI (2.1.226) with the scripted mock model, one hook per run, * marker file as the oracle: * * | matcher | tool called | fired | * | -------------------- | ----------------------------------------------- | ----- | * | `Write` | `Write` | yes | * | `Writ` / `rit` | `Write` | NO | * | `rit.` | `Write` | yes | * | `W(rit)e` | `Write` | yes | * | `mcp__.*` | `mcp__some_server__list_events` | yes | * | `mcp__.*__.*` | `mcp__some_server__list_events` | yes | * | `mcp__[^_]+__[^_]+` | `mcp__some_server__list_events` | NO | * | `mcp__[^_]+__[^_]+` | `mcp__4f54037d-…-6130f3da1ef8__list_events` | yes | * | `mcp__\w+__\w+` | `mcp__some_server__list_events` | yes | * | `mcp__\w+__\w+` | `mcp__4f54037d-…-6130f3da1ef8__list_events` | NO | * * Two facts follow, and the detector encodes exactly these: * * 1. A matcher with NO regex metacharacter is matched by STRING EQUALITY * (`rit` does not fire on `Write`, though it is a substring). * 2. A matcher WITH metacharacters is matched as an UNANCHORED regex * (`rit.` fires on `Write`; `mcp__[^_]+__[^_]+` fires on the hyphenated * server because `[^_]+` only has to reach *into* the tool segment). * * ## What is flagged (five kinds) * * 1. **tool-typo** — a LITERAL bare token that is a close typo (edit distance ≤ 2) * of a real built-in tool (`bash` → `Bash`). Reuses `closestTool`. * 2. **invalid-regex** — the matcher does not COMPILE. A dead hook no other check * catches, and its own finding rather than a silent skip. * 3. **mcp-form** — an MCP-ish matcher that can match NO MCP tool name at all: * a literal that isn't the `mcp____` shape (`mcp_memory_search`), * or a pattern that matches none of the synthetic probes (`mcp_memory_*`). * 4. **mcp-narrow** — an MCP-ish pattern that DOES fire, but not on the server * naming that occurs in the wild. `mcp__[^_]+__[^_]+` cannot cross the `_` in * `Google_Calendar`; `mcp__\w+__\w+` cannot cross the `-` in the uuid form — * and the SAME server appears both ways in different sessions. This is a real * gap but it is NOT "never fires", and the message says so. * 5. **mcp-undeclared** — a matcher pinning a literal `mcp____…` the * plugin doesn't declare. Gated exactly like `mcp-tool-resolves`. * * ## Why patterns are validated by PROBING, not by shape * * The server segment is not stable: the same Google Calendar server is * `mcp__Google_Calendar__list_events` in one session and * `mcp__4f54037d-…__list_events` in another, so a hook keyed to one literal id * dies silently when the id changes — patterns are the CORRECT authoring form. * Validating a pattern against the literal shape therefore inverts the verdict: * it rejected `mcp__.*` (fires on everything) and accepted `mcp__[^_]+__[^_]+` * (fires on nothing with an underscored server). So a pattern is instead COMPILED * and run against synthetic probes — including a probe whose server segment holds * an underscore and one whose server segment holds hyphens, because both occur. * Probes are also DERIVED from the matcher's own literal segments, so a correctly * server-scoped `mcp__memory__.*` is never called unreachable (#131). * * FP-SAFE, unchanged in spirit: a match-all (`*`, `.*`, `**`, empty) or an * ALTERNATION (`Edit|Write`) is skipped — each arm of an alternation would have * to be judged separately, and a mixed arm set is legitimate. A non-MCP token * carrying regex/glob syntax is skipped too (it is a pattern over built-in tool * names, not a name). * * Pure + ONE detector reused by `scan` + the `hook-matcher` lint rule * (one-detector-no-drift). The dialect is injected (core ⊄ adapter). */ import type { HarnessDialect } from "./dialect.js"; /** Which matching failure was detected in the hook matcher string. */ export type HookMatcherKind = "tool-typo" | "invalid-regex" | "mcp-form" | "mcp-narrow" | "mcp-undeclared"; /** One finding for a hook matcher that doesn't fire the way it reads. */ export interface HookMatcherFinding { /** The matcher string exactly as written. */ readonly matcher: string; /** Which class of error was detected. */ readonly kind: HookMatcherKind; /** * The corrected matcher when the intent is recoverable (e.g. `Bash` for * `bash`, `mcp__memory__.*` for `mcp_memory_*`). Absent when the server * segment can't be recovered from a malformed MCP form. * * INVARIANT (property-tested): a suggestion, fed back through this detector, * produces no finding — the advice converges in one step. */ readonly suggestion?: string; /** A ready-to-show, actionable message. */ readonly message: string; } /** A single hook registration entry — its event and matcher string. */ export interface HookMatcherEntry { readonly event: string; readonly matcher: string; } /** * Verify hook-matcher strings for the ways a matcher fails to fire as written. * Returns one {@link HookMatcherFinding} per offending entry. De-duplicates * repeated matchers. Returns `[]` when every matcher is FP-safe to skip or is * correct. */ export declare function hookMatcherIssues(entries: readonly HookMatcherEntry[], declaredServers: readonly string[], dialect: HarnessDialect): HookMatcherFinding[]; /** * How a matcher relates to one tool call — the answer a boolean could not carry. * * - `"selects"` — the harness spawns the hook for this call. * - `"misses"` — it does not, because the matcher names something else. * - `"uncompilable"` — it does not, because the harness cannot BUILD the matcher. * * The last two both mean "the hook does not run", and a caller that only asks * `=== "selects"` still lands on the safe answer; they are separate because only * the third is a DEFECT worth naming in a report. */ export type MatcherReach = "selects" | "misses" | "uncompilable"; /** * Would this matcher select a call to `tool` — i.e. does the harness spawn the * hook at all? * * The same two MEASURED facts the module header pins, asked as a question rather * than as a defect: on a harness whose matchers are tool names (Claude Code), a * matcher with no regex metacharacter is compared by string EQUALITY and one * with metacharacters is an UNANCHORED regex. It lives here and not in the * caller so those semantics have one home (one-detector-no-drift) — * `hookMatcherIssues` judges a matcher, this one applies it. * * FAIL-OPEN WHERE THE HARNESS IS, AND NOT ONE STEP FURTHER. An absent matcher or * a match-all really does select every tool, so answering `"selects"` there * states a fact — the same direction `decideHookCondition` * (`core/hook-condition.ts`) fails open, and for the same reason it gives: where * Claude Code cannot tell, it RUNS the hook, so mirroring it can only ever add a * run, never invent a skip. * * 🔴 THAT REASONING DOES NOT REACH AN UNCOMPILABLE MATCHER, and this function * used to apply it there anyway. `Bash(` is what the `invalid-regex` finding * above already reports as "the harness can't compile it, so the hook never * fires" — the harness fails CLOSED. Answering `"selects"` therefore does not * add a run the harness makes, it MANUFACTURES one: a caller feeds the hook a * battery it would never have been handed, and an unconditional-deny body scores * a full pass for a hook that cannot run. That is the false-confidence class * this module exists to remove, so an uncompilable matcher gets its own answer * and the caller declines to score it. * * @param matcher - the registration's matcher, or `null` when it declares none. * @param tool - the tool named by the call, e.g. `"Bash"`. * @param style - the active harness's `HookProtocol.matcherStyle`. `"exact"` * (the default, Claude Code) applies the literal-equality rule above; * `"regex"` (Codex) compiles EVERY matcher, so `ash` matches `Bash` and the * glob spellings `*` / `**` — which are Claude Code's documented match-all, * not regexes — come back `"uncompilable"` rather than being assumed to be * special-cased by a harness nobody measured. */ export declare function hookMatcherReach(matcher: string | null, tool: string, style?: "exact" | "regex"): MatcherReach; //# sourceMappingURL=hook-matcher.d.ts.map