vigiles 15.0.0 → 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/adapters/claude-code/run-scripts.d.ts +55 -5
- package/dist/adapters/claude-code/run-scripts.js +105 -24
- package/dist/audit-score.js +1 -1
- package/dist/check-count.d.ts +40 -0
- package/dist/check-count.js +136 -0
- 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/lethal-trifecta.d.ts +31 -2
- package/dist/core/lethal-trifecta.js +32 -7
- package/dist/core/rule-meta.js +1 -1
- package/dist/core/skill-resources.js +41 -9
- package/dist/core/types.d.ts +10 -6
- package/dist/core/validate.js +3 -2
- package/dist/eval.js +6 -0
- package/dist/harness-assert.js +14 -4
- package/dist/harness-test.js +4 -0
- package/dist/run-script.js +6 -0
- package/dist/scan-core.js +56 -2
- package/dist/scan.d.ts +4 -3
- package/dist/scan.js +1 -1
- package/dist/score-core.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +9 -1
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -86,16 +86,45 @@ export declare function bashGrantIsUnbounded(raw: string): boolean;
|
|
|
86
86
|
* by {@link lethalTrifectaIssues}, which knows it grants every leg.
|
|
87
87
|
*/
|
|
88
88
|
export declare function classifyTrifectaLegs(tools: readonly string[], dialect: HarnessDialect): TrifectaLegs;
|
|
89
|
+
/** Extra facts about HOW the contract was obtained, which change the verdict. */
|
|
90
|
+
export interface TrifectaContext {
|
|
91
|
+
/**
|
|
92
|
+
* The unit's frontmatter block EXISTS but is not valid YAML.
|
|
93
|
+
*
|
|
94
|
+
* 🔴 WHY THE DETECTOR NEEDS TO KNOW. vigiles's frontmatter reader is
|
|
95
|
+
* deliberately lenient: on a block js-yaml rejects it regex-SALVAGES the
|
|
96
|
+
* fields, so the live PreToolUse rail still has something to enforce. Right
|
|
97
|
+
* for a rail, wrong for a score. Measured 2026-08-08:
|
|
98
|
+
* `readFrontmatter(bad).malformed` is `true` — the tool KNOWS the block is
|
|
99
|
+
* broken — while `frontmatterList(…, "allowed-tools")` on it still returns
|
|
100
|
+
* `["Read","Bash"]`, the narrow contract its author MEANT. A unit whose
|
|
101
|
+
* contract a strict loader rejects was therefore graded as though it had
|
|
102
|
+
* declared exactly that list, and scored CLEAN. Presence of a declaration is
|
|
103
|
+
* not enforcement of it — the product's own thesis, turned on the product.
|
|
104
|
+
*
|
|
105
|
+
* With this set, `tools` is read as a SALVAGE, not a contract: it can only make
|
|
106
|
+
* the verdict worse, never better. A salvaged list that names all three legs
|
|
107
|
+
* still fires `"hard"` (both readings of the file agree the unit holds them);
|
|
108
|
+
* anything less falls back to what a strict loader actually yields — no
|
|
109
|
+
* contract at all, i.e. inherits-all, which is the `"advisory"` finding.
|
|
110
|
+
*/
|
|
111
|
+
readonly contractUnreadable?: boolean;
|
|
112
|
+
}
|
|
89
113
|
/**
|
|
90
114
|
* Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
|
|
91
115
|
* `null` (≤ 2 legs = safe by the Rule of Two).
|
|
92
116
|
*
|
|
93
|
-
*
|
|
117
|
+
* Three paths:
|
|
94
118
|
* - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
|
|
95
119
|
* tool → trivially all three legs → an `"advisory"` finding (the inherits-all
|
|
96
120
|
* stance: a footgun worth surfacing, not a declared exfil path).
|
|
97
121
|
* - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
|
|
98
122
|
* three legs is non-empty.
|
|
123
|
+
* - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from
|
|
124
|
+
* a salvage of a block a strict loader rejects. They can only make the verdict
|
|
125
|
+
* WORSE — a salvaged all-three still fires `"hard"` — and anything short of
|
|
126
|
+
* that falls back to what a strict loader really yields: no contract, i.e.
|
|
127
|
+
* inherits-all, the `"advisory"` finding. Never the other way round.
|
|
99
128
|
*/
|
|
100
|
-
export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect): TrifectaFinding | null;
|
|
129
|
+
export declare function lethalTrifectaIssues(tools: readonly string[], dialect: HarnessDialect, ctx?: TrifectaContext): TrifectaFinding | null;
|
|
101
130
|
//# sourceMappingURL=lethal-trifecta.d.ts.map
|
|
@@ -249,14 +249,19 @@ function classifyTrifectaLegs(tools, dialect) {
|
|
|
249
249
|
* Returns a {@link TrifectaFinding} ONLY when a unit holds all three legs, else
|
|
250
250
|
* `null` (≤ 2 legs = safe by the Rule of Two).
|
|
251
251
|
*
|
|
252
|
-
*
|
|
252
|
+
* Three paths:
|
|
253
253
|
* - INHERITS-ALL (a wildcard `""`/`"*"`, or an EMPTY contract): inherits every
|
|
254
254
|
* tool → trivially all three legs → an `"advisory"` finding (the inherits-all
|
|
255
255
|
* stance: a footgun worth surfacing, not a declared exfil path).
|
|
256
256
|
* - EXPLICIT: classify the named tools; emit a `"hard"` finding iff each of the
|
|
257
257
|
* three legs is non-empty.
|
|
258
|
+
* - UNREADABLE ({@link TrifectaContext.contractUnreadable}): the names came from
|
|
259
|
+
* a salvage of a block a strict loader rejects. They can only make the verdict
|
|
260
|
+
* WORSE — a salvaged all-three still fires `"hard"` — and anything short of
|
|
261
|
+
* that falls back to what a strict loader really yields: no contract, i.e.
|
|
262
|
+
* inherits-all, the `"advisory"` finding. Never the other way round.
|
|
258
263
|
*/
|
|
259
|
-
function lethalTrifectaIssues(tools, dialect) {
|
|
264
|
+
function lethalTrifectaIssues(tools, dialect, ctx = {}) {
|
|
260
265
|
const hasWildcard = tools.some((t) => isWildcard(baseTool(t)));
|
|
261
266
|
// Inherits-all is signalled by a WILDCARD (the caller passes `["*"]` for an
|
|
262
267
|
// absent `tools:` line). An EXPLICIT empty `[]` is the opposite — zero tools,
|
|
@@ -271,10 +276,17 @@ function lethalTrifectaIssues(tools, dialect) {
|
|
|
271
276
|
return {
|
|
272
277
|
severity: "advisory",
|
|
273
278
|
legs,
|
|
274
|
-
message:
|
|
275
|
-
"
|
|
276
|
-
|
|
277
|
-
|
|
279
|
+
message: ctx.contractUnreadable
|
|
280
|
+
? "Frontmatter is not valid YAML, so the declared tool list could not be read — a " +
|
|
281
|
+
"strict loader rejects the block, and a regex salvage of it is a guess, not a " +
|
|
282
|
+
"contract. Scored as INHERITS-ALL (every capability), which is what a strict " +
|
|
283
|
+
"loader yields: it therefore holds all three lethal-trifecta legs (read private " +
|
|
284
|
+
"data, ingest untrusted content, exfiltrate). Fix the YAML and the declared list " +
|
|
285
|
+
"counts again — a declaration that does not parse is not an enforcement."
|
|
286
|
+
: "Inherits-all contract (no explicit tools / wildcard) grants every capability — " +
|
|
287
|
+
"it holds all three lethal-trifecta legs (read private data, ingest untrusted content, " +
|
|
288
|
+
"exfiltrate) and is a maximal prompt-injection blast radius. Declare an explicit tools " +
|
|
289
|
+
"list dropping at least one leg (Meta's Rule of Two).",
|
|
278
290
|
};
|
|
279
291
|
}
|
|
280
292
|
const legs = classifyTrifectaLegs(tools, dialect);
|
|
@@ -288,9 +300,22 @@ function lethalTrifectaIssues(tools, dialect) {
|
|
|
288
300
|
`(${legs.private.join(", ")}), ingest untrusted content ` +
|
|
289
301
|
`(${legs.untrusted.join(", ")}), AND exfiltrate ` +
|
|
290
302
|
`(${legs.exfil.join(", ")}) — a prompt-injection exfil path with no exploit code. ` +
|
|
291
|
-
"Drop at least one leg (Meta's Rule of Two: allow at most two)."
|
|
303
|
+
"Drop at least one leg (Meta's Rule of Two: allow at most two)." +
|
|
304
|
+
(ctx.contractUnreadable
|
|
305
|
+
? " (Those tool names were SALVAGED: the frontmatter is not valid YAML, so a strict" +
|
|
306
|
+
" loader reads no contract here at all and the real grant may be wider still." +
|
|
307
|
+
" Fix the YAML — this finding stands either way.)"
|
|
308
|
+
: ""),
|
|
292
309
|
};
|
|
293
310
|
}
|
|
311
|
+
// Fewer than three legs — but the names were salvaged from a block a strict
|
|
312
|
+
// loader rejects, so "fewer" is a guess. What that loader actually yields is
|
|
313
|
+
// NOTHING: no contract, which is inherits-all, which holds every leg. This is
|
|
314
|
+
// the branch the defect lived in — a malformed unit scored clean because the
|
|
315
|
+
// salvage happened to read narrow.
|
|
316
|
+
if (ctx.contractUnreadable) {
|
|
317
|
+
return lethalTrifectaIssues(["*"], dialect, ctx);
|
|
318
|
+
}
|
|
294
319
|
return null;
|
|
295
320
|
}
|
|
296
321
|
//# sourceMappingURL=lethal-trifecta.js.map
|
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
|
},
|
|
@@ -33,8 +33,9 @@ exports.skillResourceIssues = skillResourceIssues;
|
|
|
33
33
|
* `references/finance.md`", or a markdown-link example demonstrating how to
|
|
34
34
|
* write a path — e.g. one with a space in the filename). A reference (link OR
|
|
35
35
|
* inline path) is treated as real ONLY when the line DIRECTS the agent to use
|
|
36
|
-
* the file (read/run/see
|
|
37
|
-
*
|
|
36
|
+
* the file (read/run/see/…, or the line is a HEADING naming it — see
|
|
37
|
+
* `MD_HEADING`) and carries no illustrative cue (example / e.g. / such as /
|
|
38
|
+
* would be / template / →). See `inlinePathIsUsed`.
|
|
38
39
|
*
|
|
39
40
|
* ESCAPE HATCH: a SKILL.md carrying `<!-- vigiles-disable skill-resource-resolves -->`
|
|
40
41
|
* anywhere in its body opts OUT of this check entirely (mirrors `orphans.ts`'s
|
|
@@ -179,18 +180,49 @@ const USE_DIRECTIVE = /\b(run|runs|execute|executes|read|reads|load|loads|open|o
|
|
|
179
180
|
// pointing at a shipped file. Includes the `→`/`->` arrow used in "move detail
|
|
180
181
|
// → `references/x.md`" authoring lists.
|
|
181
182
|
const ILLUSTRATIVE_CUE = /\b(example|examples|e\.g\.?|i\.e\.?|such as|for instance|would be|helpful|useful|template|boilerplate)\b|→|->/i;
|
|
183
|
+
/**
|
|
184
|
+
* An ATX markdown heading (`## …`, up to three leading spaces per CommonMark).
|
|
185
|
+
*
|
|
186
|
+
* 🔴 WHY A HEADING COUNTS AS A USE-DIRECTIVE. The verb gate below reads PROSE,
|
|
187
|
+
* and a heading is not prose — it is the section's label. So the single most
|
|
188
|
+
* common way a skill points at its own bundled script, naming it in the heading
|
|
189
|
+
* of the section about running it, carried no verb and went unchecked:
|
|
190
|
+
*
|
|
191
|
+
* ## 🏗 START WITH THE MECHANICAL LEG — `scripts/structure.mjs`
|
|
192
|
+
*
|
|
193
|
+
* Measured 2026-08-08 on a real skill whose `structure.mjs` sits at the skill
|
|
194
|
+
* ROOT, not under `scripts/`: that line yielded NOTHING. Rewriting the same
|
|
195
|
+
* heading to "Run the mechanical leg" — same file, same ref, same missing
|
|
196
|
+
* target — correctly yielded the finding. The tool's answer depended on the
|
|
197
|
+
* author's choice of verb, and it stayed silent for three days.
|
|
198
|
+
*
|
|
199
|
+
* The narrow fix, and deliberately not the wide one. The alternative — treat
|
|
200
|
+
* ANY bundle-dir path with an extension as a reference, verb or not — reopens
|
|
201
|
+
* exactly the false positives the gate exists for (a skill TEACHING how to
|
|
202
|
+
* build skills mentions `scripts/rotate.py` constantly as prose). A heading
|
|
203
|
+
* naming a bundle path is a structural claim about what this section is about,
|
|
204
|
+
* not a sentence illustrating what a skill *could* ship, so it earns the same
|
|
205
|
+
* standing as an explicit "run …". The illustrative-cue veto still applies —
|
|
206
|
+
* `## Examples: \`references/finance.md\`` stays skipped — and the check is
|
|
207
|
+
* `warn` severity with a `vigiles-disable` escape hatch, so a slightly wider
|
|
208
|
+
* net is affordable where a blanket one is not.
|
|
209
|
+
*/
|
|
210
|
+
const MD_HEADING = /^\s{0,3}#{1,6}\s/;
|
|
182
211
|
/**
|
|
183
212
|
* Whether a bundle-path reference on this line reads as a REAL reference (the
|
|
184
213
|
* agent is told to use the file) rather than an illustrative mention. Requires
|
|
185
|
-
* a positive
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
214
|
+
* a positive directive — a use verb, or the line being a heading (see
|
|
215
|
+
* {@link MD_HEADING}) — and the absence of an illustrative cue, both evaluated
|
|
216
|
+
* over the whole line for simplicity (a tight, precise rule over a clever one).
|
|
217
|
+
* BOTH candidate shapes consult this (issue #110) — a markdown link's
|
|
218
|
+
* `[text](target)` syntax is a stronger signal than a bare inline span, but
|
|
219
|
+
* "For example, see [the schema](...)" is still an illustrative mention, not a
|
|
220
|
+
* real dead ref.
|
|
191
221
|
*/
|
|
192
222
|
function inlinePathIsUsed(line) {
|
|
193
|
-
|
|
223
|
+
if (ILLUSTRATIVE_CUE.test(line))
|
|
224
|
+
return false;
|
|
225
|
+
return USE_DIRECTIVE.test(line) || MD_HEADING.test(line);
|
|
194
226
|
}
|
|
195
227
|
/** Collect candidate bundled-resource refs from one body line, skipping fences. */
|
|
196
228
|
function candidatesInLine(line, lineNo) {
|