vigiles 15.0.1 → 15.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -176,7 +176,7 @@ function structure(r) {
176
176
  {
177
177
  n: r.hookMatcherFindings.length,
178
178
  weight: score_core_js_1.W_MISSING_HOOK,
179
- label: "hook matcher(s) that never fire (typo / wrong MCP form)",
179
+ label: "hook matcher(s) that don't fire as written (dead, or too narrow for real MCP names)",
180
180
  },
181
181
  ]);
182
182
  // inherit-all (no `tools:` line) is ADVISORY, not graded: it's surfaced as a
package/dist/cli.js CHANGED
@@ -1127,8 +1127,9 @@ async function runLint(restArgs, flags, config) {
1127
1127
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1128
1128
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1129
1129
  const hookBlock = checkHookBlockIneffective(config, silent, adapter, scanRoot);
1130
- // 7u. Hook-matcher — a hook `matcher` that never fires (tool-name typo, or a
1131
- // malformed/undeclared MCP form).
1130
+ // 7u. Hook-matcher — a hook `matcher` that doesn't fire as written (tool-name
1131
+ // typo, an uncompilable or unreachable MCP pattern, one too narrow for real
1132
+ // server naming, or an undeclared MCP server).
1132
1133
  const hookMatcher = checkHookMatcher(config, silent, adapter, scanRoot);
1133
1134
  // 8. Validate vigiles builder calls inside markdown code blocks. Default
1134
1135
  // is to validate every ref; illustrative blocks opt out via
@@ -3445,8 +3446,10 @@ function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
3445
3446
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
3446
3447
  }
3447
3448
  /**
3448
- * Apply the `hook-matcher` rule: a hook `matcher` string that silently never
3449
- * fires — a tool-name typo (`bash`→`Bash`) or a malformed/undeclared MCP form.
3449
+ * Apply the `hook-matcher` rule: a hook `matcher` string that doesn't fire as
3450
+ * written — a tool-name typo (`bash`→`Bash`), a matcher that doesn't compile, an
3451
+ * MCP pattern that reaches no tool name or is too narrow for real server naming,
3452
+ * or an undeclared MCP server.
3450
3453
  * Reuses `scanPlugin`'s `hookMatcherFindings` (one detector, no drift). Warning
3451
3454
  * by default; "error" gates CI.
3452
3455
  */
@@ -1,42 +1,81 @@
1
1
  /**
2
2
  * Hook-matcher verification — the cross-referencing moat applied to the MATCHER
3
- * string inside a hook registration. A PreToolUse hook fires only when its
4
- * `matcher` equals the tool name the harness emits (or matches via glob/regex); a
5
- * typo or wrong form silently prevents the hook from ever running — exactly the
6
- * FALSE CONFIDENCE failure the compiled-hooks design exists to eliminate
7
- * (research/hook-pain-points.md).
3
+ * string inside a hook registration. A hook fires only when its `matcher` selects
4
+ * the tool name the harness emits; a typo or an unmatchable pattern silently
5
+ * prevents the hook from ever running — exactly the FALSE CONFIDENCE failure the
6
+ * compiled-hooks design exists to eliminate (research/hook-pain-points.md).
8
7
  *
9
- * THREE kinds of bad matcher, each verified here (one-detector-no-drift):
8
+ * ## The matching semantics this detector models (MEASURED, not assumed)
10
9
  *
11
- * 1. **tool-typo** a bare token that is a CLOSE TYPO (edit distance ≤ 2) of a
12
- * real built-in tool name but not an exact match (`bash` `Bash`, `read` →
13
- * `Read`). Suggests the correct casing. Reuses `closestTool` from
14
- * `tool-contract.ts` — same edit-distance logic, same ≤ 2 confidence bound.
10
+ * A matcher is NOT a literal tool name — it is a pattern. Measured against the
11
+ * real `claude` CLI (2.1.226) with the scripted mock model, one hook per run,
12
+ * marker file as the oracle:
15
13
  *
16
- * 2. **mcp-form** a token that looks MCP-ish (starts with `mcp`, case-
17
- * insensitive) but is NOT the required `mcp__<server>__<tool>` double-underscore
18
- * shape (single underscores, a hyphen, a trailing `*`…). Suggests the corrected
19
- * form when the server/tool segments can be recovered.
14
+ * | matcher | tool called | fired |
15
+ * | -------------------- | ----------------------------------------------- | ----- |
16
+ * | `Write` | `Write` | yes |
17
+ * | `Writ` / `rit` | `Write` | NO |
18
+ * | `rit.` | `Write` | yes |
19
+ * | `W(rit)e` | `Write` | yes |
20
+ * | `mcp__.*` | `mcp__some_server__list_events` | yes |
21
+ * | `mcp__.*__.*` | `mcp__some_server__list_events` | yes |
22
+ * | `mcp__[^_]+__[^_]+` | `mcp__some_server__list_events` | NO |
23
+ * | `mcp__[^_]+__[^_]+` | `mcp__4f54037d-…-6130f3da1ef8__list_events` | yes |
24
+ * | `mcp__\w+__\w+` | `mcp__some_server__list_events` | yes |
25
+ * | `mcp__\w+__\w+` | `mcp__4f54037d-…-6130f3da1ef8__list_events` | NO |
20
26
  *
21
- * 3. **mcp-undeclared** a correctly-formed `mcp__<server>__…` token whose server
22
- * is NOT in the plugin's declared MCP servers. Gated EXACTLY like
23
- * `mcp-tool-resolves`: (a) no declared set → skip (reaches global/project
24
- * servers); (b) built-ins allowlisted via `dialect.knownMcpServers`; (c) the
25
- * plugin-namespaced `mcp__plugin_…__…` form is skipped. Reuses `mcpToolServer`
26
- * from `mcp-tool.ts` for the extraction — one parser, no drift.
27
+ * Two facts follow, and the detector encodes exactly these:
27
28
  *
28
- * FP-SAFE: only a SINGLE bare token is inspected. A matcher that is empty, a pure
29
- * wildcard (`*` / `.*`), or contains alternation (`|`) or other regex meta-
30
- * characters is skipped it is a pattern/glob with legitimate broad matching, not
31
- * a tool name. Same don't-cry-wolf discipline as every other vigiles detector.
29
+ * 1. A matcher with NO regex metacharacter is matched by STRING EQUALITY
30
+ * (`rit` does not fire on `Write`, though it is a substring).
31
+ * 2. A matcher WITH metacharacters is matched as an UNANCHORED regex
32
+ * (`rit.` fires on `Write`; `mcp__[^_]+__[^_]+` fires on the hyphenated
33
+ * server because `[^_]+` only has to reach *into* the tool segment).
34
+ *
35
+ * ## What is flagged (five kinds)
36
+ *
37
+ * 1. **tool-typo** — a LITERAL bare token that is a close typo (edit distance ≤ 2)
38
+ * of a real built-in tool (`bash` → `Bash`). Reuses `closestTool`.
39
+ * 2. **invalid-regex** — the matcher does not COMPILE. A dead hook no other check
40
+ * catches, and its own finding rather than a silent skip.
41
+ * 3. **mcp-form** — an MCP-ish matcher that can match NO MCP tool name at all:
42
+ * a literal that isn't the `mcp__<server>__<tool>` shape (`mcp_memory_search`),
43
+ * or a pattern that matches none of the synthetic probes (`mcp_memory_*`).
44
+ * 4. **mcp-narrow** — an MCP-ish pattern that DOES fire, but not on the server
45
+ * naming that occurs in the wild. `mcp__[^_]+__[^_]+` cannot cross the `_` in
46
+ * `Google_Calendar`; `mcp__\w+__\w+` cannot cross the `-` in the uuid form —
47
+ * and the SAME server appears both ways in different sessions. This is a real
48
+ * gap but it is NOT "never fires", and the message says so.
49
+ * 5. **mcp-undeclared** — a matcher pinning a literal `mcp__<server>__…` the
50
+ * plugin doesn't declare. Gated exactly like `mcp-tool-resolves`.
51
+ *
52
+ * ## Why patterns are validated by PROBING, not by shape
53
+ *
54
+ * The server segment is not stable: the same Google Calendar server is
55
+ * `mcp__Google_Calendar__list_events` in one session and
56
+ * `mcp__4f54037d-…__list_events` in another, so a hook keyed to one literal id
57
+ * dies silently when the id changes — patterns are the CORRECT authoring form.
58
+ * Validating a pattern against the literal shape therefore inverts the verdict:
59
+ * it rejected `mcp__.*` (fires on everything) and accepted `mcp__[^_]+__[^_]+`
60
+ * (fires on nothing with an underscored server). So a pattern is instead COMPILED
61
+ * and run against synthetic probes — including a probe whose server segment holds
62
+ * an underscore and one whose server segment holds hyphens, because both occur.
63
+ * Probes are also DERIVED from the matcher's own literal segments, so a correctly
64
+ * server-scoped `mcp__memory__.*` is never called unreachable (#131).
65
+ *
66
+ * FP-SAFE, unchanged in spirit: a match-all (`*`, `.*`, `**`, empty) or an
67
+ * ALTERNATION (`Edit|Write`) is skipped — each arm of an alternation would have
68
+ * to be judged separately, and a mixed arm set is legitimate. A non-MCP token
69
+ * carrying regex/glob syntax is skipped too (it is a pattern over built-in tool
70
+ * names, not a name).
32
71
  *
33
72
  * Pure + ONE detector reused by `scan` + the `hook-matcher` lint rule
34
73
  * (one-detector-no-drift). The dialect is injected (core ⊄ adapter).
35
74
  */
36
75
  import type { HarnessDialect } from "./dialect.js";
37
76
  /** Which matching failure was detected in the hook matcher string. */
38
- export type HookMatcherKind = "tool-typo" | "mcp-form" | "mcp-undeclared";
39
- /** One finding for a hook matcher that will silently never fire. */
77
+ export type HookMatcherKind = "tool-typo" | "invalid-regex" | "mcp-form" | "mcp-narrow" | "mcp-undeclared";
78
+ /** One finding for a hook matcher that doesn't fire the way it reads. */
40
79
  export interface HookMatcherFinding {
41
80
  /** The matcher string exactly as written. */
42
81
  readonly matcher: string;
@@ -46,6 +85,9 @@ export interface HookMatcherFinding {
46
85
  * The corrected matcher when the intent is recoverable (e.g. `Bash` for
47
86
  * `bash`, `mcp__memory__.*` for `mcp_memory_*`). Absent when the server
48
87
  * segment can't be recovered from a malformed MCP form.
88
+ *
89
+ * INVARIANT (property-tested): a suggestion, fed back through this detector,
90
+ * produces no finding — the advice converges in one step.
49
91
  */
50
92
  readonly suggestion?: string;
51
93
  /** A ready-to-show, actionable message. */
@@ -57,10 +99,10 @@ export interface HookMatcherEntry {
57
99
  readonly matcher: string;
58
100
  }
59
101
  /**
60
- * Verify hook-matcher strings for the three forms that silently never fire.
102
+ * Verify hook-matcher strings for the ways a matcher fails to fire as written.
61
103
  * Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
62
- * repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
63
- * are correct.
104
+ * repeated matchers. Returns `[]` when every matcher is FP-safe to skip or is
105
+ * correct.
64
106
  */
65
107
  export declare function hookMatcherIssues(entries: readonly HookMatcherEntry[], declaredServers: readonly string[], dialect: HarnessDialect): HookMatcherFinding[];
66
108
  //# sourceMappingURL=hook-matcher.d.ts.map
@@ -4,178 +4,292 @@ exports.hookMatcherIssues = hookMatcherIssues;
4
4
  const tool_contract_js_1 = require("./tool-contract.js");
5
5
  const mcp_tool_js_1 = require("./mcp-tool.js");
6
6
  // ---------------------------------------------------------------------------
7
+ // The probe corpus
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Server segments an MCP tool name really carries. `Google_Calendar` is
11
+ * Anthropic's own connector naming (an underscore INSIDE the server segment);
12
+ * the uuid is the SAME server as it appears in another session (hyphens). A
13
+ * matcher meant to catch "MCP tools" has to reach both.
14
+ */
15
+ const PROBE_SERVERS = [
16
+ "srv",
17
+ "Google_Calendar",
18
+ "4f54037d-0499-426a-8573-6130f3da1ef8",
19
+ ];
20
+ /** Tool segments: plain, underscored, and the second underscored form. */
21
+ const PROBE_TOOLS = ["tool", "list_events", "update_event"];
22
+ /** The simplest possible MCP tool name — "does this pattern match MCP at all". */
23
+ const PROBE_SIMPLE = "mcp__srv__tool";
24
+ /**
25
+ * The two REAL-SHAPE probes a generic MCP matcher must also reach. Both are
26
+ * measured: `mcp__[^_]+__[^_]+` does not fire on the first, `mcp__\w+__\w+`
27
+ * does not fire on the second.
28
+ */
29
+ const REAL_SHAPE_PROBES = [
30
+ "mcp__Google_Calendar__list_events",
31
+ "mcp__4f54037d-0499-426a-8573-6130f3da1ef8__update_event",
32
+ ];
33
+ /** The widest correct MCP matcher — what a too-narrow one should become. */
34
+ const WIDE_MCP_MATCHER = "mcp__.*__.*";
35
+ /** Match-all matchers the harness special-cases (and `*` isn't even a regex). */
36
+ const MATCH_ALL = new Set(["", "*", "**", ".*"]);
37
+ /** Cap on segments harvested from a matcher — bounds the probe corpus. */
38
+ const MAX_DERIVED_SEGMENTS = 4;
39
+ // ---------------------------------------------------------------------------
7
40
  // Internal helpers
8
41
  // ---------------------------------------------------------------------------
42
+ /** Regex metacharacters — their presence is what makes a matcher a PATTERN. */
43
+ const REGEX_META = /[\\^$.*+?()[\]{}|]/;
44
+ /** A matcher with no metacharacter is compared by string equality (measured). */
45
+ function isLiteralMatcher(matcher) {
46
+ return !REGEX_META.test(matcher);
47
+ }
48
+ /** Compile a matcher, or null when the regex engine rejects it. */
49
+ function compileMatcher(matcher) {
50
+ try {
51
+ return new RegExp(matcher);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
9
57
  /**
10
- * Whether a matcher token should be skipped for FP-safety. We ONLY inspect
11
- * a SINGLE bare token that could plausibly be a literal tool name or MCP
12
- * reference. Anything with regex / glob meta-characters, alternation, a
13
- * trailing glob wildcard alone, or an empty string is a pattern — skip it.
14
- *
15
- * Conservative by design: an unrecognized form → skip, never flag.
58
+ * A token starts with `mcp` followed by a separator it is trying to be an MCP
59
+ * tool reference, whether or not it succeeds. A leading `^` is tolerated so an
60
+ * ANCHORED pattern (`^mcp__srv$`, which cannot reach the tool segment) is judged
61
+ * as MCP rather than skipped as an unknown built-in.
16
62
  */
17
- function isInspectableToken(token) {
18
- if (token.length === 0)
19
- return false;
20
- // Pure wildcard forms used as "match-all" matchers.
21
- if (token === "*" || token === ".*" || token === "**")
22
- return false;
23
- // Contains regex alternation a combined matcher, not a single tool name.
24
- if (token.includes("|"))
25
- return false;
26
- // Contains a parenthesised group `()` — regex, not a tool name.
63
+ function looksMcpIsh(token) {
64
+ return /^\^?mcp[_-]/i.test(token);
65
+ }
66
+ /**
67
+ * Whether a NON-MCP token should be inspected as a possible tool-name typo. We
68
+ * only inspect a single bare token that could plausibly BE a tool name; regex /
69
+ * glob syntax means it is a pattern over tool names, not one. Conservative by
70
+ * design: an unrecognized form → skip, never flag.
71
+ */
72
+ function isBareToolToken(token) {
27
73
  if (token.includes("(") || token.includes(")"))
28
74
  return false;
29
- // Contains a `[` — character class; skip.
30
75
  if (token.includes("["))
31
76
  return false;
32
- // A leading `^` or trailing `$` — anchored regex.
33
77
  if (token.startsWith("^") || token.endsWith("$"))
34
78
  return false;
35
- // Leading `.*` — regex prefix; always a pattern.
36
79
  if (token.startsWith(".*"))
37
80
  return false;
38
- // A trailing `.*`/`*` is a glob/regex suffix on a plain TOOL matcher (`Bash.*`,
39
- // `Read*`) → skip. But for an MCP-ish token the trailing wildcard is EXACTLY
40
- // what we must inspect: `mcp__server__.*` is the legitimate match-all-tools
41
- // form, and `mcp_memory_*` is the classic single-underscore typo we want to
42
- // catch — so do NOT skip a wildcard suffix on an `mcp`-ish token.
43
- if (!looksMcpIsh(token) && (token.endsWith(".*") || token.endsWith("*")))
81
+ if (token.endsWith(".*") || token.endsWith("*"))
44
82
  return false;
45
83
  return true;
46
84
  }
47
85
  /**
48
- * A token starts with `mcp` (case-insensitive) and contains at least one
49
- * `_` (making it look like an MCP tool reference, not a harness built-in).
86
+ * Strip the regex anchors so an anchored matcher (`^mcp__memory__.*$`) is read
87
+ * structurally the same as its unanchored twin. The anchors stay in the compiled
88
+ * regex — this is only for reading the matcher's literal segments.
50
89
  */
51
- function looksMcpIsh(token) {
52
- return /^mcp[_-]/i.test(token);
90
+ function withoutAnchors(matcher) {
91
+ return matcher.replace(/^\^/, "").replace(/\$$/, "");
92
+ }
93
+ /** The literal server segment of `mcp__<server>__…`, or null when it's a pattern. */
94
+ function literalServerSegment(matcher) {
95
+ return /^mcp__([A-Za-z0-9_-]+)__/.exec(withoutAnchors(matcher))?.[1] ?? null;
96
+ }
97
+ /** Literal name-shaped runs inside one segment of a matcher (`mem.*` → `mem`). */
98
+ function literalRuns(segment) {
99
+ return (segment.match(/[A-Za-z0-9][A-Za-z0-9_-]*/g) ?? []).slice(0, MAX_DERIVED_SEGMENTS);
53
100
  }
54
101
  /**
55
- * Whether `token` matches the canonical `mcp__<server>__<rest>` double-
56
- * underscore shape (the valid MCP matcher form). We use the dialect's own
57
- * `mcpToolPattern` extended to allow trailing `.*` for wildcard matchers,
58
- * since a hook `matcher` may be `mcp__server__.*` (match-all-tools-on-server).
102
+ * Synthetic MCP tool names to test a matcher against: the generic corpus (the
103
+ * real-world server/tool shapes) PLUS names built from the matcher's OWN literal
104
+ * segments, so a legitimately scoped `mcp__memory__search.*` has something to
105
+ * match. Derivation is POSITIONAL segments are read from the `mcp__`-split
106
+ * positions they occupy, never re-used as a different segment — so a malformed
107
+ * `mcp_memory_search` cannot manufacture a probe that rescues it.
59
108
  */
60
- function isValidMcpForm(token, dialect) {
61
- // The canonical pattern from the dialect: `mcp__server__tool`.
62
- if (dialect.mcpToolPattern.test(token))
63
- return true;
64
- // Also allow the wildcard suffix form `mcp__server__.*`.
65
- if (/^mcp__[a-z0-9_-]+__\.\*$/i.test(token))
66
- return true;
67
- return false;
109
+ function mcpProbes(matcher) {
110
+ const parts = withoutAnchors(matcher).split("__");
111
+ const derivedServers = parts[0] === "mcp" && parts.length > 1 ? literalRuns(parts[1]) : [];
112
+ const derivedTools = parts[0] === "mcp" && parts.length > 2
113
+ ? literalRuns(parts.slice(2).join("__"))
114
+ : [];
115
+ const servers = [...derivedServers, ...PROBE_SERVERS];
116
+ const tools = [...derivedTools, ...PROBE_TOOLS];
117
+ const probes = [];
118
+ for (const server of servers)
119
+ for (const tool of tools)
120
+ probes.push(`mcp__${server}__${tool}`);
121
+ return probes;
68
122
  }
69
123
  /**
70
- * Attempt to recover the server segment from a malformed MCP token so we can
71
- * suggest the corrected `mcp__<server>__.*` form. Returns null when no
72
- * segment can be confidently recovered.
124
+ * Recover the server segment from a malformed MCP token so the corrected
125
+ * `mcp__<server>__.*` form can be suggested. Returns null when nothing
126
+ * name-shaped can be recovered (then the message spells the form out instead).
73
127
  *
74
- * Handles:
75
- * - Single-underscore: `mcp_memory_search` → server=`memory`, tool=`search`
76
- * - Hyphenated: `mcp-memory-search` server=`memory`, tool=`search`
77
- * - Glob suffix: `mcp_memory_*` server=`memory`
78
- * - Mixed: `mcp__memory_*` → only one `__` segment found
128
+ * Handles the `__`-separated form first — the segment the user actually wrote is
129
+ * kept whole (`mcp__memory_search` → `memory_search`, since a real server IS
130
+ * named like `Google_Calendar`) then the single-underscore / hyphen typos
131
+ * (`mcp_memory_search`, `mcp-memory-search`, `mcp_memory_*` `memory`).
79
132
  */
80
133
  function recoverMcpServer(token) {
81
- // Strip a leading `mcp` and then a separator (`__`, `_`, `-`).
82
- const rest = token.replace(/^mcp(?:__|_|-)/i, "");
83
- if (!rest || rest === token)
134
+ const parts = withoutAnchors(token).split("__");
135
+ const candidate = parts.length > 1 && parts[1].length > 0
136
+ ? parts[1]
137
+ : firstSeparatedSegment(token);
138
+ if (candidate === null)
139
+ return null;
140
+ // A recovered segment must be name-shaped, or the "suggestion" would be a
141
+ // regex fragment — the bug that made the old advice grow `__.*` forever.
142
+ return /^[A-Za-z][A-Za-z0-9_-]*$/.test(candidate) ? candidate : null;
143
+ }
144
+ /** The segment after a single `_`/`-` separator following the `mcp` prefix. */
145
+ function firstSeparatedSegment(token) {
146
+ const rest = withoutAnchors(token).replace(/^mcp(?:_|-)/i, "");
147
+ if (rest === token || rest.length === 0)
84
148
  return null;
85
- // Split on single underscores or hyphens (not `__`) to get the next segment.
86
- // We want the first non-empty segment after the `mcp` prefix separator.
87
- const segments = rest.split(/(?<!_)_(?!_)|(?<!-)(?:-(?!-))/);
88
- const server = segments[0];
89
- if (!server || server.length === 0)
149
+ const segment = rest.split(/(?<!_)_(?!_)|-/)[0];
150
+ return segment.length > 0 ? segment : null;
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // Finding builders
154
+ // ---------------------------------------------------------------------------
155
+ /** The matcher can match NO MCP tool name — the hook is dead. */
156
+ function unreachableFinding(matcher) {
157
+ const server = recoverMcpServer(matcher);
158
+ const suggestion = server === null ? undefined : `mcp__${server}__.*`;
159
+ const hint = suggestion === undefined
160
+ ? " Use the form `mcp__<server>__<tool>` (double underscores), or a pattern that produces it."
161
+ : ` Did you mean "${suggestion}"?`;
162
+ return {
163
+ matcher,
164
+ kind: "mcp-form",
165
+ ...(suggestion === undefined ? {} : { suggestion }),
166
+ message: `Hook matcher "${matcher}" matches no MCP tool name — MCP tools are named \`mcp__<server>__<tool>\`, so this hook never fires.${hint}`,
167
+ };
168
+ }
169
+ /**
170
+ * The matcher fires on some MCP tools but misses real-world server naming. The
171
+ * message names the probes it actually misses — not the whole corpus — so the
172
+ * finding is checkable rather than a vague "too narrow".
173
+ */
174
+ function narrowFinding(matcher, missed) {
175
+ const names = missed.map((m) => `"${m}"`).join(" or ");
176
+ return {
177
+ matcher,
178
+ kind: "mcp-narrow",
179
+ suggestion: WIDE_MCP_MATCHER,
180
+ message: `Hook matcher "${matcher}" fires on some MCP tools but not on ${names} — real server segments contain "_" and "-" (the same server appears as \`mcp__Google_Calendar__…\` in one session and \`mcp__<uuid>__…\` in another), so this matcher silently skips them. Did you mean "${WIDE_MCP_MATCHER}"?`,
181
+ };
182
+ }
183
+ /** The matcher isn't a regex the engine accepts — it can never match. */
184
+ function invalidRegexFinding(matcher) {
185
+ return {
186
+ matcher,
187
+ kind: "invalid-regex",
188
+ message: `Hook matcher "${matcher}" is not a valid regular expression — the harness can't compile it, so the hook never fires.`,
189
+ };
190
+ }
191
+ // ---------------------------------------------------------------------------
192
+ // Per-matcher checks
193
+ // ---------------------------------------------------------------------------
194
+ /**
195
+ * The shape half of the MCP check: can this matcher produce an MCP tool name at
196
+ * all, and if so does it reach the ones that occur in the wild? `re` is null for
197
+ * a literal matcher (compared by string equality, so only the shape can be
198
+ * checked).
199
+ */
200
+ function mcpShapeFinding(matcher, re, dialect) {
201
+ if (re === null)
202
+ return dialect.mcpToolPattern.test(matcher)
203
+ ? null
204
+ : unreachableFinding(matcher);
205
+ if (!mcpProbes(matcher).some((p) => re.test(p)))
206
+ return unreachableFinding(matcher);
207
+ // The narrowness check applies only to a matcher meant to be GENERIC: one that
208
+ // pins no literal server yet matches the simplest MCP name. A matcher scoped to
209
+ // one server (or to specific tools) is narrow ON PURPOSE — never flag it.
210
+ if (literalServerSegment(matcher) !== null || !re.test(PROBE_SIMPLE))
211
+ return null;
212
+ const missed = REAL_SHAPE_PROBES.filter((p) => !re.test(p));
213
+ return missed.length === 0 ? null : narrowFinding(matcher, missed);
214
+ }
215
+ /**
216
+ * The resolution half: a matcher pinning a literal server the plugin doesn't
217
+ * declare can't fire. Gated EXACTLY like `mcp-tool-resolves` — no declared set →
218
+ * silent (the server may be user-global), built-ins allowlisted, the
219
+ * plugin-namespaced form skipped.
220
+ */
221
+ function mcpUndeclaredFinding(matcher, declaredServers, dialect) {
222
+ if (declaredServers.length === 0)
223
+ return null;
224
+ const server = (0, mcp_tool_js_1.mcpToolServer)(matcher, dialect) ?? literalServerSegment(matcher);
225
+ if (server === null)
226
+ return null;
227
+ if (/^plugin_/i.test(server))
228
+ return null;
229
+ const known = new Set([
230
+ ...declaredServers,
231
+ ...(dialect.knownMcpServers ?? []),
232
+ ]);
233
+ if (known.has(server))
234
+ return null;
235
+ return {
236
+ matcher,
237
+ kind: "mcp-undeclared",
238
+ message: `Hook matcher "${matcher}" references MCP server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't fire.`,
239
+ };
240
+ }
241
+ /** A literal bare token that is a close typo of a real built-in tool. */
242
+ function toolTypoFinding(matcher, dialect) {
243
+ if (!isBareToolToken(matcher))
244
+ return null;
245
+ if (new Set(dialect.builtinAgentTools).has(matcher))
246
+ return null;
247
+ const near = (0, tool_contract_js_1.closestTool)(matcher, dialect);
248
+ if (near === null)
249
+ return null; // far/unknown → likely a plugin tool, not a typo
250
+ return {
251
+ matcher,
252
+ kind: "tool-typo",
253
+ suggestion: near,
254
+ message: `Hook matcher "${matcher}" doesn't match any built-in tool — the hook silently never fires. Did you mean "${near}"?`,
255
+ };
256
+ }
257
+ /** The whole per-matcher decision. Null when the matcher is fine (or skipped). */
258
+ function matcherFinding(matcher, declaredServers, dialect) {
259
+ if (MATCH_ALL.has(matcher))
90
260
  return null;
91
- // Reject segments that are clearly numeric-only or single chars (too ambiguous).
92
- if (/^\d+$/.test(server))
261
+ // Alternation: each arm would have to be judged on its own, and a mixed set
262
+ // (`mcp__x__y|Bash`) is legitimate — skip, same don't-cry-wolf discipline.
263
+ if (matcher.includes("|"))
93
264
  return null;
94
- return server;
265
+ const literal = isLiteralMatcher(matcher);
266
+ const re = literal ? null : compileMatcher(matcher);
267
+ if (!literal && re === null)
268
+ return invalidRegexFinding(matcher);
269
+ if (looksMcpIsh(matcher))
270
+ return (mcpShapeFinding(matcher, re, dialect) ??
271
+ mcpUndeclaredFinding(matcher, declaredServers, dialect));
272
+ return literal ? toolTypoFinding(matcher, dialect) : null;
95
273
  }
96
274
  // ---------------------------------------------------------------------------
97
275
  // Public detector
98
276
  // ---------------------------------------------------------------------------
99
277
  /**
100
- * Verify hook-matcher strings for the three forms that silently never fire.
278
+ * Verify hook-matcher strings for the ways a matcher fails to fire as written.
101
279
  * Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
102
- * repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
103
- * are correct.
280
+ * repeated matchers. Returns `[]` when every matcher is FP-safe to skip or is
281
+ * correct.
104
282
  */
105
283
  function hookMatcherIssues(entries, declaredServers, dialect) {
106
284
  const findings = [];
107
285
  const seen = new Set();
108
286
  for (const { matcher } of entries) {
109
- // De-dupe repeated matchers across entries.
110
287
  if (seen.has(matcher))
111
- continue;
288
+ continue; // de-dupe repeated matchers across entries
112
289
  seen.add(matcher);
113
- // Skip wildcards, alternation, regex patterns — FP-safety.
114
- if (!isInspectableToken(matcher))
115
- continue;
116
- // ── kind: mcp-form ──────────────────────────────────────────────────────
117
- // The token looks MCP-ish but is NOT the valid double-underscore form.
118
- if (looksMcpIsh(matcher)) {
119
- if (!isValidMcpForm(matcher, dialect)) {
120
- const server = recoverMcpServer(matcher);
121
- const suggestion = server ? `mcp__${server}__.*` : undefined;
122
- const hintPart = suggestion !== undefined
123
- ? ` Did you mean "${suggestion}"?`
124
- : " Use the form `mcp__<server>__<tool>` (double underscores).";
125
- findings.push({
126
- matcher,
127
- kind: "mcp-form",
128
- ...(suggestion !== undefined ? { suggestion } : {}),
129
- message: `Hook matcher "${matcher}" is not a valid MCP tool reference (requires double underscores: \`mcp__server__tool\`).${hintPart}`,
130
- });
131
- continue;
132
- }
133
- // ── kind: mcp-undeclared ──────────────────────────────────────────────
134
- // A correctly-formed MCP token whose server isn't in the declared set.
135
- // Guard 1: no declared set → skip (reaches global/project servers).
136
- if (declaredServers.length === 0)
137
- continue;
138
- // `mcpToolServer` reads the `mcp__server__tool` form; a server-wide WILDCARD
139
- // matcher (`mcp__server__.*`) isn't a concrete tool, so fall back to the
140
- // wildcard server segment so an undeclared server is still caught.
141
- const server = (0, mcp_tool_js_1.mcpToolServer)(matcher, dialect) ??
142
- /^mcp__([a-z0-9_-]+)__\.\*$/i.exec(matcher)?.[1] ??
143
- null;
144
- if (server === null)
145
- continue; // plugin-namespaced form → guard 3, skip
146
- // The plugin-namespaced `mcp__plugin_<plugin>_<server>__` form is the
147
- // plugin's OWN server — never an undeclared reference (mirrors mcpToolServer).
148
- if (/^plugin_/i.test(server))
149
- continue;
150
- const known = new Set([
151
- ...declaredServers,
152
- ...(dialect.knownMcpServers ?? []),
153
- ]);
154
- // Guard 2: built-in server → skip.
155
- if (known.has(server))
156
- continue;
157
- findings.push({
158
- matcher,
159
- kind: "mcp-undeclared",
160
- message: `Hook matcher "${matcher}" references MCP server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't fire.`,
161
- });
162
- continue;
163
- }
164
- // ── kind: tool-typo ─────────────────────────────────────────────────────
165
- // A bare token that is NOT an exact built-in tool but IS a close typo of one.
166
- const knownTools = new Set(dialect.builtinAgentTools);
167
- if (knownTools.has(matcher))
168
- continue; // exact match → no issue
169
- // Reuse the same ≤ 2 edit-distance helper from tool-contract.ts.
170
- const near = (0, tool_contract_js_1.closestTool)(matcher, dialect);
171
- if (near === null)
172
- continue; // far/unknown → likely a plugin tool, not a typo
173
- findings.push({
174
- matcher,
175
- kind: "tool-typo",
176
- suggestion: near,
177
- message: `Hook matcher "${matcher}" doesn't match any built-in tool — the hook silently never fires. Did you mean "${near}"?`,
178
- });
290
+ const finding = matcherFinding(matcher, declaredServers, dialect);
291
+ if (finding !== null)
292
+ findings.push(finding);
179
293
  }
180
294
  return findings;
181
295
  }
@@ -139,7 +139,7 @@ exports.RULE_META = {
139
139
  bucket: "structural-closed",
140
140
  surface: ["hook"],
141
141
  defaultSeverity: "warn",
142
- summary: "A hook matcher fires (no tool-name typo / malformed MCP form).",
142
+ summary: "A hook matcher fires as written (no tool-name typo, no MCP pattern that reaches nothing or misses real server names).",
143
143
  detector: "hookMatcherIssues",
144
144
  upstreamPrevention: "compiled hook tool()/tools() matcher is typed",
145
145
  },
@@ -297,12 +297,16 @@ export interface RulesConfig {
297
297
  */
298
298
  "hook-block-ineffective"?: RuleSeverity;
299
299
  /**
300
- * Flag a hook `matcher` string that silently never fires — a close typo of a
301
- * built-in tool (`bash`→`Bash`), or a malformed/undeclared MCP form
302
- * (`mcp_memory_*` instead of `mcp__memory__.*`, or a server the plugin doesn't
303
- * declare). High-precision (close-typo only; MCP gated on a declared set,
304
- * built-ins allowlisted; wildcards/regex skipped). Default "warn"; raise to
305
- * "error" to gate CI. Same detector as `scan` (hookMatcherFindings). See
300
+ * Flag a hook `matcher` string that doesn't fire the way it reads — a close
301
+ * typo of a built-in tool (`bash`→`Bash`), a matcher that doesn't COMPILE, an
302
+ * MCP pattern that can match no tool name at all (`mcp_memory_*` instead of
303
+ * `mcp__memory__.*`), an MCP pattern too narrow for real server naming
304
+ * (`mcp__[^_]+__[^_]+` can't cross the `_` in `mcp__Google_Calendar__…`), or a
305
+ * server the plugin doesn't declare. A matcher is a PATTERN, so patterns are
306
+ * validated by compiling and probing, never by literal shape. High-precision
307
+ * (close-typo only; MCP gated on a declared set, built-ins allowlisted;
308
+ * match-all and alternation skipped). Default "warn"; raise to "error" to gate
309
+ * CI. Same detector as `scan` (hookMatcherFindings). See
306
310
  * docs/rules/hook-matcher.md.
307
311
  */
308
312
  "hook-matcher"?: RuleSeverity;
@@ -94,8 +94,9 @@ exports.DEFAULT_RULES = {
94
94
  // A hook that looks like it blocks but silently doesn't (#19009) — WARN by
95
95
  // default (FP-safe literal patterns); raise to error to gate CI.
96
96
  "hook-block-ineffective": "warn",
97
- // A hook matcher that never fires (tool typo / wrong MCP form) — WARN by
98
- // default (high-precision); raise to error to gate CI.
97
+ // A hook matcher that doesn't fire as written (tool typo, an MCP pattern
98
+ // that matches no tool name, or one too narrow for real server names) — WARN
99
+ // by default (high-precision); raise to error to gate CI.
99
100
  "hook-matcher": "warn",
100
101
  };
101
102
  const DEFAULT_CONFIG = {
package/dist/scan.d.ts CHANGED
@@ -257,9 +257,10 @@ export interface ScanReport {
257
257
  */
258
258
  readonly hookBlockFindings: readonly HookBlockFinding[];
259
259
  /**
260
- * Hook `matcher` strings that silently never fire — a tool-name typo or a
261
- * malformed/undeclared MCP form. Shared by `scan` and the `hook-matcher` lint
262
- * rule (one detector, no drift).
260
+ * Hook `matcher` strings that don't fire as written — a tool-name typo, a
261
+ * matcher that doesn't compile, an MCP pattern that matches no tool name or is
262
+ * too narrow for real server naming, or an undeclared MCP server. Shared by
263
+ * `scan` and the `hook-matcher` lint rule (one detector, no drift).
263
264
  */
264
265
  readonly hookMatcherFindings: readonly HookMatcherFinding[];
265
266
  /** Skills/agents whose `---` block isn't valid YAML — informational (may still load via salvage). */
package/dist/scan.js CHANGED
@@ -431,7 +431,7 @@ function formatScanReport(r) {
431
431
  out.push(...section("Misplaced plugin directories", r.pluginLayoutIssues.map((p) => ` ✗ ${p.message}`)));
432
432
  out.push(...section("Lethal trifecta across delegation (blast radius)", r.delegationTrifecta.map((d) => ` ⚠ ${d.finding.name} (${d.path}): ${d.finding.message}`)));
433
433
  out.push(...section("Ineffective hook guards (false confidence)", r.hookBlockFindings.map((h) => ` ✗ [${h.event}] ${h.scriptPath ?? "(inline)"}: ${h.message}`)));
434
- out.push(...section("Hook matchers that never fire", r.hookMatcherFindings.map((m) => ` ✗ ${m.message}`)));
434
+ out.push(...section("Hook matchers that don't fire as written", r.hookMatcherFindings.map((m) => ` ✗ ${m.message}`)));
435
435
  const facts = [];
436
436
  if (r.commands > 0)
437
437
  facts.push(`Commands: ${String(r.commands)}`);
@@ -212,7 +212,7 @@ function reportDeductions(r) {
212
212
  {
213
213
  n: r.hookMatcherFindings.length,
214
214
  weight: exports.W_MISSING_HOOK,
215
- label: "hook matcher(s) that never fire (typo / wrong MCP form)",
215
+ label: "hook matcher(s) that don't fire as written (dead, or too narrow for real MCP names)",
216
216
  },
217
217
  // NB: delegationTrifecta (like the advisory per-unit/inherits-all trifecta) is a
218
218
  // ⚠ RISK, surfaced but NOT graded — only the HARD per-unit trifecta above scores.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "15.0.1",
3
+ "version": "15.0.2",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",