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.
@@ -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
  }
@@ -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
- * Two paths:
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
- * Two paths:
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: "Inherits-all contract (no explicit tools / wildcard) grants every capability — " +
275
- "it holds all three lethal-trifecta legs (read private data, ingest untrusted content, " +
276
- "exfiltrate) and is a maximal prompt-injection blast radius. Declare an explicit tools " +
277
- "list dropping at least one leg (Meta's Rule of Two).",
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
@@ -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
  },
@@ -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/…) and carries no illustrative cue (example / e.g. /
37
- * such as / would be / template / →). See `inlinePathIsUsed`.
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 use-directive and the absence of an illustrative cue — both
186
- * evaluated over the whole line for simplicity (a tight, precise rule over a
187
- * clever one). BOTH candidate shapes consult this (issue #110) — a markdown
188
- * link's `[text](target)` syntax is a stronger signal than a bare inline span,
189
- * but "For example, see [the schema](...)" is still an illustrative mention,
190
- * not a real dead ref.
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
- return USE_DIRECTIVE.test(line) && !ILLUSTRATIVE_CUE.test(line);
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) {