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.
- package/dist/audit-score.js +1 -1
- package/dist/cli.js +7 -4
- package/dist/core/hook-matcher.d.ts +71 -29
- package/dist/core/hook-matcher.js +245 -131
- package/dist/core/rule-meta.js +1 -1
- package/dist/core/types.d.ts +10 -6
- package/dist/core/validate.js +3 -2
- package/dist/scan.d.ts +4 -3
- package/dist/scan.js +1 -1
- package/dist/score-core.js +1 -1
- package/package.json +1 -1
package/dist/audit-score.js
CHANGED
|
@@ -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
|
|
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
|
|
1131
|
-
//
|
|
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
|
|
3449
|
-
*
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
8
|
+
* ## The matching semantics this detector models (MEASURED, not assumed)
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
|
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
|
|
63
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
49
|
-
*
|
|
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
|
|
52
|
-
return
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
//
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
103
|
-
*
|
|
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
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
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
|
}
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -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
|
|
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
|
},
|
package/dist/core/types.d.ts
CHANGED
|
@@ -297,12 +297,16 @@ export interface RulesConfig {
|
|
|
297
297
|
*/
|
|
298
298
|
"hook-block-ineffective"?: RuleSeverity;
|
|
299
299
|
/**
|
|
300
|
-
* Flag a hook `matcher` string that
|
|
301
|
-
* built-in tool (`bash`→`Bash`),
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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;
|
package/dist/core/validate.js
CHANGED
|
@@ -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
|
|
98
|
-
//
|
|
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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
|
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)}`);
|
package/dist/score-core.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|