vigiles 5.0.0 → 5.1.0

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.
Files changed (38) hide show
  1. package/README.md +82 -116
  2. package/dist/adapters/claude-code/agent-runtime.d.ts +10 -0
  3. package/dist/adapters/claude-code/agent-runtime.js +15 -29
  4. package/dist/adapters/claude-code/dialect.js +18 -2
  5. package/dist/adapters/codex/eval.d.ts +94 -0
  6. package/dist/adapters/codex/eval.js +227 -0
  7. package/dist/cli.js +464 -8
  8. package/dist/codex.d.ts +1 -0
  9. package/dist/codex.js +3 -0
  10. package/dist/core/compile.js +8 -36
  11. package/dist/core/description-overlap.d.ts +27 -0
  12. package/dist/core/description-overlap.js +53 -0
  13. package/dist/core/dialect.d.ts +8 -0
  14. package/dist/core/frontmatter-read.d.ts +25 -0
  15. package/dist/core/frontmatter-read.js +138 -0
  16. package/dist/core/hook-events.d.ts +34 -0
  17. package/dist/core/hook-events.js +48 -0
  18. package/dist/core/mcp-config.d.ts +20 -0
  19. package/dist/core/mcp-config.js +40 -0
  20. package/dist/core/mcp-hook.d.ts +35 -0
  21. package/dist/core/mcp-hook.js +70 -0
  22. package/dist/core/mcp-tool.d.ts +50 -0
  23. package/dist/core/mcp-tool.js +61 -0
  24. package/dist/core/tool-contract.d.ts +68 -0
  25. package/dist/core/tool-contract.js +113 -0
  26. package/dist/core/types.d.ts +89 -0
  27. package/dist/core/validate.js +22 -0
  28. package/dist/eval.d.ts +69 -13
  29. package/dist/eval.js +106 -51
  30. package/dist/leaderboard.js +61 -3
  31. package/dist/plugin-loader.d.ts +1 -0
  32. package/dist/plugin-loader.js +71 -18
  33. package/dist/scan-behavioral.d.ts +73 -0
  34. package/dist/scan-behavioral.js +150 -0
  35. package/dist/scan.d.ts +126 -1
  36. package/dist/scan.js +559 -40
  37. package/package.json +27 -4
  38. package/skills/migrate-to-spec/SKILL.md +0 -2
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OVERLAP_NCD_CUTOFF = void 0;
4
+ exports.findDescriptionOverlaps = findDescriptionOverlaps;
5
+ /**
6
+ * Description-overlap — a DETERMINISTIC proxy for a behavioral risk. Two skills
7
+ * whose descriptions are near-identical can't be told apart by the model's
8
+ * selector, so the wrong one fires (a precision collision). This catches a
9
+ * `--trigger`-class problem with NO model, reusing the NCD engine in proofs.ts
10
+ * (the same one `findSimilarRules` uses) — the bridge between the deterministic
11
+ * and behavioral columns, and a check no other plugin linter has.
12
+ *
13
+ * Calibrated HIGH-PRECISION against the mid-2026 sweep: across 4678 within-plugin
14
+ * skill-description pairs, the MOST-similar legitimately-distinct pair
15
+ * (`create-issue` vs `create-pr`) sits at NCD 0.25, and NOTHING falls below it.
16
+ * So a cutoff of NCD < 0.2 fires only on text that's essentially identical (a
17
+ * copy-pasted description with a word or two changed) — never on a parallel but
18
+ * distinct pair. Warn-level, reports the PAIR (not a unilateral defect).
19
+ */
20
+ const proofs_js_1 = require("./proofs.js");
21
+ /**
22
+ * The NCD cutoff below which two descriptions count as a near-duplicate. 0.2 sits
23
+ * safely under the sweep's most-similar legitimately-distinct pair (0.25), so
24
+ * only basically-identical text is flagged. Exported so a caller / test can see
25
+ * the calibrated value.
26
+ */
27
+ exports.OVERLAP_NCD_CUTOFF = 0.2;
28
+ /**
29
+ * Find near-duplicate description pairs among `surfaces`. Returns one
30
+ * {@link DescriptionOverlap} per pair whose NCD is below `cutoff`, most-similar
31
+ * first. Pure; pass only the surfaces that actually compete for auto-selection
32
+ * (model-invocable, described) so a user-invoked pair isn't a false alarm.
33
+ */
34
+ function findDescriptionOverlaps(surfaces, cutoff = exports.OVERLAP_NCD_CUTOFF) {
35
+ const overlaps = [];
36
+ for (let i = 0; i < surfaces.length; i++) {
37
+ for (let j = i + 1; j < surfaces.length; j++) {
38
+ const d = (0, proofs_js_1.ncd)(surfaces[i].description, surfaces[j].description);
39
+ if (d >= cutoff)
40
+ continue;
41
+ const a = surfaces[i].name;
42
+ const b = surfaces[j].name;
43
+ overlaps.push({
44
+ a,
45
+ b,
46
+ similarity: Math.round((1 - d) * 100) / 100,
47
+ message: `skills "${a}" and "${b}" have near-identical descriptions (${String(Math.round((1 - d) * 100))}% alike) — the model can't reliably tell them apart, so the wrong one may fire. Differentiate their descriptions.`,
48
+ });
49
+ }
50
+ }
51
+ return overlaps.sort((x, y) => y.similarity - x.similarity);
52
+ }
53
+ //# sourceMappingURL=description-overlap.js.map
@@ -32,6 +32,14 @@ export interface HarnessDialect {
32
32
  readonly neverAvailableTools: readonly string[];
33
33
  /** Matches an MCP tool reference, e.g. `mcp__server__tool`. */
34
34
  readonly mcpToolPattern: RegExp;
35
+ /**
36
+ * MCP servers the harness provides itself, available to a contract WITHOUT the
37
+ * plugin declaring them — e.g. Claude Code's built-in `ide` integration
38
+ * (`mcp__ide__getDiagnostics`). The `mcp-tool-resolves` check allowlists these
39
+ * so a reference to a built-in server is never flagged as an undeclared one.
40
+ * Optional (additive, non-breaking for existing adapters) — defaults to none.
41
+ */
42
+ readonly knownMcpServers?: readonly string[];
35
43
  /** Hook event names the harness fires. */
36
44
  readonly hookEvents: readonly string[];
37
45
  /** Instruction-file targets the harness reads (also the h1 heading). */
@@ -0,0 +1,25 @@
1
+ export interface FrontmatterRead {
2
+ /** Parsed mapping when the block is valid YAML, else null (malformed or scalar). */
3
+ readonly data: Record<string, unknown> | null;
4
+ /** Raw text inside the leading `---` fences, or null when there's no block. */
5
+ readonly block: string | null;
6
+ /** True when a leading `---` block EXISTS but is NOT valid YAML. */
7
+ readonly malformed: boolean;
8
+ }
9
+ /** Extract + parse the leading frontmatter block, never throwing. */
10
+ export declare function readFrontmatter(markdown: string): FrontmatterRead;
11
+ /**
12
+ * A top-level scalar field — from parsed YAML when valid, else a regex salvage
13
+ * from the raw block (handling a block scalar `>`/`|` and a quoted value that
14
+ * starts on the next indented line).
15
+ */
16
+ export declare function frontmatterScalar(fm: FrontmatterRead, key: string): string | undefined;
17
+ /**
18
+ * A tool-list field (`tools:` / `disallowedTools:`) — an array or a comma list,
19
+ * normalized to string[]. Returns `null` when the key is ABSENT (the "no
20
+ * contract / inherits all" signal the rail honors) and `[]` when the key is
21
+ * PRESENT but empty ("no tools"). Salvages from the raw block when YAML is
22
+ * malformed, so the rail still reads the contract.
23
+ */
24
+ export declare function frontmatterList(fm: FrontmatterRead, key: string): string[] | null;
25
+ //# sourceMappingURL=frontmatter-read.d.ts.map
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readFrontmatter = readFrontmatter;
4
+ exports.frontmatterScalar = frontmatterScalar;
5
+ exports.frontmatterList = frontmatterList;
6
+ /**
7
+ * Lenient frontmatter reader — ONE reader for the SKILL.md / subagent `---` block,
8
+ * shared by `scan` and the PreToolUse rail (`agent-runtime`). It is deliberately
9
+ * fault-tolerant: it audits arbitrary third-party files, so it must never throw
10
+ * and must salvage the few scalar/list fields it needs even from a block that
11
+ * isn't valid YAML.
12
+ *
13
+ * The strategy is "real parser, with a safety net": try `js-yaml` (so block
14
+ * scalars, quoted/multi-line values, and flow arrays parse correctly for free);
15
+ * if the block isn't valid YAML, fall back to a regex salvage of the requested
16
+ * field and record `malformed: true` (the signal the `frontmatter-valid` rule
17
+ * reports). A single bad line therefore never blanks out a whole file's metadata.
18
+ *
19
+ * This replaces three divergent hand-parsers (the old `readField` in scan.ts and
20
+ * the regex parse in agent-runtime.ts); `core/frontmatter.ts` is a DIFFERENT
21
+ * concern (the Level-1 `vigiles:` rule block) and is untouched.
22
+ */
23
+ const js_yaml_1 = require("js-yaml");
24
+ // Frontmatter is the very first thing in the file. Anchoring at the start — not
25
+ // `(?:^|\n)` — means a `---` horizontal rule in the BODY is never mistaken for
26
+ // frontmatter (which matters for the malformed-YAML verdict). A leading BOM is
27
+ // stripped first; an optional leading HTML comment is allowed too — vigiles
28
+ // stamps a compiled file with `<!-- vigiles:sha256:… -->` before the `---`.
29
+ const BLOCK_RE = /^\uFEFF?(?:<!--[\s\S]*?-->\s*)?---\r?\n([\s\S]*?)\r?\n---/;
30
+ /** A YAML block-scalar indicator: `>`/`|` with optional chomp (`+`/`-`) + indent digit. */
31
+ const BLOCK_SCALAR_RE = /^[|>][+-]?\d*$/;
32
+ /** Extract + parse the leading frontmatter block, never throwing. */
33
+ function readFrontmatter(markdown) {
34
+ const m = BLOCK_RE.exec(markdown);
35
+ if (!m)
36
+ return { data: null, block: null, malformed: false };
37
+ const block = m[1];
38
+ try {
39
+ const parsed = (0, js_yaml_1.load)(block);
40
+ if (parsed !== null &&
41
+ typeof parsed === "object" &&
42
+ !Array.isArray(parsed)) {
43
+ return {
44
+ data: parsed,
45
+ block,
46
+ malformed: false,
47
+ };
48
+ }
49
+ // Valid YAML but not a mapping (e.g. a bare scalar) — usable as no data, but
50
+ // not "malformed": it parsed fine. Salvage will read fields from the block.
51
+ return { data: null, block, malformed: false };
52
+ }
53
+ catch (e) {
54
+ if (e instanceof js_yaml_1.YAMLException)
55
+ return { data: null, block, malformed: true };
56
+ throw e;
57
+ }
58
+ }
59
+ /**
60
+ * A top-level scalar field — from parsed YAML when valid, else a regex salvage
61
+ * from the raw block (handling a block scalar `>`/`|` and a quoted value that
62
+ * starts on the next indented line).
63
+ */
64
+ function frontmatterScalar(fm, key) {
65
+ if (fm.data && Object.prototype.hasOwnProperty.call(fm.data, key)) {
66
+ const v = fm.data[key];
67
+ if (typeof v === "string")
68
+ return v.trim() || undefined;
69
+ if (typeof v === "number" || typeof v === "boolean")
70
+ return String(v);
71
+ return undefined; // an array/object/null isn't a scalar field
72
+ }
73
+ return fm.block === null ? undefined : salvageField(fm.block, key);
74
+ }
75
+ /**
76
+ * A tool-list field (`tools:` / `disallowedTools:`) — an array or a comma list,
77
+ * normalized to string[]. Returns `null` when the key is ABSENT (the "no
78
+ * contract / inherits all" signal the rail honors) and `[]` when the key is
79
+ * PRESENT but empty ("no tools"). Salvages from the raw block when YAML is
80
+ * malformed, so the rail still reads the contract.
81
+ */
82
+ function frontmatterList(fm, key) {
83
+ if (fm.data && Object.prototype.hasOwnProperty.call(fm.data, key)) {
84
+ const v = fm.data[key];
85
+ if (v === null)
86
+ return []; // `key:` with nothing after it → empty contract
87
+ if (Array.isArray(v))
88
+ return v.map((x) => String(x).trim()).filter((s) => s.length > 0);
89
+ if (typeof v === "string")
90
+ return splitList(v);
91
+ return [];
92
+ }
93
+ return fm.block === null ? null : salvageList(fm.block, key);
94
+ }
95
+ // --- salvage (the malformed-YAML / no-data fallback) ------------------------
96
+ /** Old `readField`: gather a possibly multi-line scalar value from the raw block. */
97
+ function salvageField(block, key) {
98
+ const lines = block.split(/\r?\n/);
99
+ const idx = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
100
+ if (idx === -1)
101
+ return undefined;
102
+ const keyIndent = /^(\s*)/.exec(lines[idx])?.[1].length ?? 0;
103
+ const inline = (new RegExp(`^${key}:[ \\t]*(.*)$`).exec(lines[idx])?.[1] ?? "").trim();
104
+ if (inline && !BLOCK_SCALAR_RE.test(inline)) {
105
+ return inline.replace(/^["']|["']$/g, "").trim() || undefined;
106
+ }
107
+ const collected = [];
108
+ for (let i = idx + 1; i < lines.length; i++) {
109
+ if (lines[i].trim() === "")
110
+ continue;
111
+ const indent = /^(\s*)/.exec(lines[i])?.[1].length ?? 0;
112
+ if (indent <= keyIndent)
113
+ break;
114
+ collected.push(lines[i].trim());
115
+ }
116
+ return (collected
117
+ .join(" ")
118
+ .trim()
119
+ .replace(/^["']/, "")
120
+ .replace(/["']$/, "")
121
+ .trim() || undefined);
122
+ }
123
+ /** Salvage a list field from the raw block (single-line key only). */
124
+ function salvageList(block, key) {
125
+ const match = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(block);
126
+ if (!match)
127
+ return null;
128
+ return splitList(match[1]);
129
+ }
130
+ /** Split a comma list or inline-array string into trimmed, de-quoted tokens. */
131
+ function splitList(raw) {
132
+ return raw
133
+ .replace(/^\[|\]$/g, "")
134
+ .split(",")
135
+ .map((t) => t.trim().replace(/^["']|["']$/g, ""))
136
+ .filter((t) => t.length > 0);
137
+ }
138
+ //# sourceMappingURL=frontmatter-read.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Hook-event verification — the cross-referencing moat applied to the EVENT a
3
+ * hook registers under. A `hooks` block keys each entry by event name
4
+ * (`PreToolUse`, `SessionStart`, …); a TYPO (`PreToolUSe`) means the hook
5
+ * silently never fires — a dead registration no generic JSON linter catches.
6
+ *
7
+ * Like the tool catalog, the event set is NOT closed in practice: frameworks
8
+ * extend it (TheBushidoCollective/han ships a custom runtime with `TeammateIdle`,
9
+ * `WorktreeRemove`, … in its own `hooks.json`). So the audit path (scan/lint) is
10
+ * HIGH-PRECISION — `confidentHookEventIssues` keeps only a close typo
11
+ * (a did-you-mean within edit distance 2), never a bare unrecognized event that
12
+ * may be a custom/future one. ONE detector (one-detector-no-drift): scan + the
13
+ * `hook-events` lint rule call the same code. Dialect injected (core ⊄ adapter).
14
+ */
15
+ import type { HarnessDialect } from "./dialect.js";
16
+ export interface HookEventIssue {
17
+ readonly event: string;
18
+ /** Closest known event (did-you-mean), or null. */
19
+ readonly suggestion: string | null;
20
+ readonly message: string;
21
+ }
22
+ /**
23
+ * The HIGH-CONFIDENCE subset (what scan / lint act on): only an unrecognized
24
+ * event that's a close typo of a real one. A bare unknown (no near match) is
25
+ * likely a framework/custom event, not a defect — never flagged when auditing.
26
+ */
27
+ export declare function confidentHookEventIssues(issues: readonly HookEventIssue[]): HookEventIssue[];
28
+ /**
29
+ * Verify hook-event names against the dialect catalog. Returns one issue per
30
+ * unrecognized event. Like the tool-contract check, a suggestion (edit distance
31
+ * ≤ 2) is the confidence signal that an unknown is really a typo of a real event.
32
+ */
33
+ export declare function verifyHookEvents(events: readonly string[], dialect: HarnessDialect): HookEventIssue[];
34
+ //# sourceMappingURL=hook-events.d.ts.map
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.confidentHookEventIssues = confidentHookEventIssues;
4
+ exports.verifyHookEvents = verifyHookEvents;
5
+ const linters_js_1 = require("./linters.js");
6
+ /** Closest known hook event by edit distance (≤ 2) — a confidence signal. */
7
+ function closestEvent(event, dialect) {
8
+ let best = null;
9
+ let bestDistance = Infinity;
10
+ for (const known of dialect.hookEvents) {
11
+ const d = (0, linters_js_1.editDistance)(event.toLowerCase(), known.toLowerCase());
12
+ if (d < bestDistance) {
13
+ bestDistance = d;
14
+ best = known;
15
+ }
16
+ }
17
+ return bestDistance <= 2 ? best : null;
18
+ }
19
+ /**
20
+ * The HIGH-CONFIDENCE subset (what scan / lint act on): only an unrecognized
21
+ * event that's a close typo of a real one. A bare unknown (no near match) is
22
+ * likely a framework/custom event, not a defect — never flagged when auditing.
23
+ */
24
+ function confidentHookEventIssues(issues) {
25
+ return issues.filter((i) => i.suggestion !== null);
26
+ }
27
+ /**
28
+ * Verify hook-event names against the dialect catalog. Returns one issue per
29
+ * unrecognized event. Like the tool-contract check, a suggestion (edit distance
30
+ * ≤ 2) is the confidence signal that an unknown is really a typo of a real event.
31
+ */
32
+ function verifyHookEvents(events, dialect) {
33
+ const known = new Set(dialect.hookEvents);
34
+ const issues = [];
35
+ for (const event of events) {
36
+ if (known.has(event))
37
+ continue;
38
+ const near = closestEvent(event, dialect);
39
+ const hint = near ? ` Did you mean "${near}"?` : "";
40
+ issues.push({
41
+ event,
42
+ suggestion: near,
43
+ message: `Unknown hook event "${event}" — a hook here never fires. Valid events: ${dialect.hookEvents.join(", ")}.${hint}`,
44
+ });
45
+ }
46
+ return issues;
47
+ }
48
+ //# sourceMappingURL=hook-events.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * MCP-config verification — a declared MCP server that can't start. Each server
3
+ * entry must say HOW to reach it: a `command` (stdio server) OR a `url` (http/sse
4
+ * server). An entry with neither is malformed — the server silently never comes
5
+ * up, and the tools it was meant to provide are missing. A JSON linter sees valid
6
+ * JSON; only an MCP-shape-aware check knows the entry is unreachable.
7
+ *
8
+ * Pure + FP-safe: the command-or-url requirement is unambiguous (not a catalog we
9
+ * might have wrong). ONE detector reused by scan + the `mcp-config` lint rule.
10
+ */
11
+ export interface McpIssue {
12
+ readonly server: string;
13
+ readonly message: string;
14
+ }
15
+ /**
16
+ * Validate a `mcpServers` map. Returns one issue per entry that declares neither
17
+ * a `command` (stdio) nor a `url` (http/sse) — the two ways to reach a server.
18
+ */
19
+ export declare function verifyMcpServers(servers: Record<string, unknown>): McpIssue[];
20
+ //# sourceMappingURL=mcp-config.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * MCP-config verification — a declared MCP server that can't start. Each server
4
+ * entry must say HOW to reach it: a `command` (stdio server) OR a `url` (http/sse
5
+ * server). An entry with neither is malformed — the server silently never comes
6
+ * up, and the tools it was meant to provide are missing. A JSON linter sees valid
7
+ * JSON; only an MCP-shape-aware check knows the entry is unreachable.
8
+ *
9
+ * Pure + FP-safe: the command-or-url requirement is unambiguous (not a catalog we
10
+ * might have wrong). ONE detector reused by scan + the `mcp-config` lint rule.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.verifyMcpServers = verifyMcpServers;
14
+ /**
15
+ * Validate a `mcpServers` map. Returns one issue per entry that declares neither
16
+ * a `command` (stdio) nor a `url` (http/sse) — the two ways to reach a server.
17
+ */
18
+ function verifyMcpServers(servers) {
19
+ const issues = [];
20
+ for (const [server, raw] of Object.entries(servers)) {
21
+ if (typeof raw !== "object" || raw === null) {
22
+ issues.push({
23
+ server,
24
+ message: `MCP server "${server}" is not a config object.`,
25
+ });
26
+ continue;
27
+ }
28
+ const cfg = raw;
29
+ const hasCommand = typeof cfg.command === "string" && cfg.command.length > 0;
30
+ const hasUrl = typeof cfg.url === "string" && cfg.url.length > 0;
31
+ if (!hasCommand && !hasUrl) {
32
+ issues.push({
33
+ server,
34
+ message: `MCP server "${server}" declares neither a "command" (stdio) nor a "url" (http/sse) — it can't start.`,
35
+ });
36
+ }
37
+ }
38
+ return issues;
39
+ }
40
+ //# sourceMappingURL=mcp-config.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * MCP-hook target verification — the cross-referencing moat applied to a
3
+ * `type: "mcp_tool"` HOOK action. Claude Code hooks support five action types
4
+ * (command / http / mcp_tool / prompt / agent); an `mcp_tool` action calls a tool
5
+ * on an already-connected MCP server and REQUIRES a `server` + `tool` field. Two
6
+ * ways it silently never fires:
7
+ *
8
+ * 1. it omits `server` or `tool` — malformed, can't dispatch (unambiguous, like
9
+ * mcp-config; always flagged);
10
+ * 2. its `server` isn't one the plugin declares — can't resolve (gated on the
11
+ * plugin shipping a declared `mcpServers` set, exactly like mcp-tool-resolves;
12
+ * built-in servers such as `ide` are allowlisted).
13
+ *
14
+ * Pure + high-precision. ONE detector reused by scan + the
15
+ * `mcp-hook-target-resolves` lint rule. The dialect is injected (core ⊄ adapter).
16
+ *
17
+ * Scope note: the matcher surface (a `mcp__server__.*` matcher naming an
18
+ * undeclared server) is a DIFFERENT, regex-shaped check left to a future
19
+ * `hook-matcher` rule — this one is the literal `mcp_tool` action target only.
20
+ */
21
+ import type { HarnessDialect } from "./dialect.js";
22
+ export type McpHookIssueKind = "incomplete" | "undeclared-server";
23
+ export interface McpHookIssue {
24
+ readonly server: string | null;
25
+ readonly kind: McpHookIssueKind;
26
+ readonly message: string;
27
+ }
28
+ /**
29
+ * Verify every `type: "mcp_tool"` hook action against the plugin's declared MCP
30
+ * servers. Returns an {@link McpHookIssue} for each incomplete action (no
31
+ * `server`/`tool`) and — when the plugin declares a server set — each action
32
+ * whose `server` is neither declared nor a known built-in.
33
+ */
34
+ export declare function verifyMcpHookTargets(hooks: unknown, declaredServers: readonly string[], dialect: HarnessDialect): McpHookIssue[];
35
+ //# sourceMappingURL=mcp-hook.d.ts.map
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyMcpHookTargets = verifyMcpHookTargets;
4
+ /**
5
+ * Collect the action objects from a hooks config. The canonical Claude Code shape
6
+ * is `{ <event>: [ { matcher?, hooks: [ <action>, … ] }, … ] }`; we also tolerate
7
+ * an entry that IS an action (a `type` field directly). Non-object input → none.
8
+ */
9
+ function collectHookActions(hooks) {
10
+ const actions = [];
11
+ if (hooks === null || typeof hooks !== "object")
12
+ return actions;
13
+ for (const groups of Object.values(hooks)) {
14
+ if (!Array.isArray(groups))
15
+ continue;
16
+ for (const group of groups) {
17
+ if (group === null || typeof group !== "object")
18
+ continue;
19
+ const g = group;
20
+ if (Array.isArray(g.hooks)) {
21
+ for (const a of g.hooks)
22
+ if (a !== null && typeof a === "object")
23
+ actions.push(a);
24
+ }
25
+ else if (typeof g.type === "string") {
26
+ actions.push(g);
27
+ }
28
+ }
29
+ }
30
+ return actions;
31
+ }
32
+ /**
33
+ * Verify every `type: "mcp_tool"` hook action against the plugin's declared MCP
34
+ * servers. Returns an {@link McpHookIssue} for each incomplete action (no
35
+ * `server`/`tool`) and — when the plugin declares a server set — each action
36
+ * whose `server` is neither declared nor a known built-in.
37
+ */
38
+ function verifyMcpHookTargets(hooks, declaredServers, dialect) {
39
+ const known = new Set([
40
+ ...declaredServers,
41
+ ...(dialect.knownMcpServers ?? []),
42
+ ]);
43
+ const issues = [];
44
+ for (const action of collectHookActions(hooks)) {
45
+ if (action.type !== "mcp_tool")
46
+ continue;
47
+ const server = typeof action.server === "string" ? action.server : "";
48
+ const tool = typeof action.tool === "string" ? action.tool : "";
49
+ if (server === "" || tool === "") {
50
+ issues.push({
51
+ server: server || null,
52
+ kind: "incomplete",
53
+ message: `mcp_tool hook is missing a ${server === "" ? "server" : "tool"} field — it can't dispatch.`,
54
+ });
55
+ continue;
56
+ }
57
+ // Gate: with no declared set the server may be user-global/project (unknowable).
58
+ if (declaredServers.length === 0)
59
+ continue;
60
+ if (known.has(server))
61
+ continue;
62
+ issues.push({
63
+ server,
64
+ kind: "undeclared-server",
65
+ message: `mcp_tool hook targets server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't resolve.`,
66
+ });
67
+ }
68
+ return issues;
69
+ }
70
+ //# sourceMappingURL=mcp-hook.js.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * MCP-tool resolution — the cross-referencing moat ("valid is not true") applied
3
+ * to an MCP tool reference's SERVER. A contract that lists `mcp__linear__search`
4
+ * names a server `linear`; if the plugin declares its own MCP servers (a
5
+ * `.mcp.json` / manifest `mcpServers` block) and `linear` isn't among them, the
6
+ * tool can't resolve — a dead contract entry. This completes the tool moat:
7
+ * `agent-tool-contract` (tool-contract.ts) verifies BUILT-IN tools but passes
8
+ * ANY `mcp__*` token unchecked; this verifies the MCP half.
9
+ *
10
+ * Calibrated HIGH-PRECISION — three guards, each learned from a real plugin in
11
+ * the mid-2026 sweep (research/plugin-structural-findings.md):
12
+ *
13
+ * 1. GATE on a declared set. Only flag when the plugin SHIPS a `mcpServers`
14
+ * declaration (a non-empty `declaredServers`). A plugin that declares no
15
+ * servers reaches user-global / project-level ones (the normal pattern —
16
+ * ananddtyagi's agents reference `mcp__ide__*` with no `.mcp.json`), so
17
+ * flagging there would cry wolf. No declared set → return nothing.
18
+ * 2. ALLOWLIST built-ins. A harness-provided server (`dialect.knownMcpServers`,
19
+ * e.g. Claude Code's `ide`) is available without a declaration — never flag it.
20
+ * 3. SKIP the plugin-namespaced form. Claude Code rewrites a plugin's own MCP
21
+ * tool to `mcp__plugin_<plugin>_<server>__<tool>` (observed on han's
22
+ * playwright-mcp: `mcp__plugin_playwright-mcp_playwright__…`). The plugin /
23
+ * server segments are joined with single underscores and are ambiguous to
24
+ * split, and the ref is by construction the plugin's OWN server — so we don't
25
+ * interpret it (parsing it would be a false-positive factory).
26
+ *
27
+ * Pure + ONE detector reused by `scan` + the `mcp-tool-resolves` lint rule
28
+ * (one-detector-no-drift). The dialect is injected (core ⊄ adapter).
29
+ */
30
+ import type { HarnessDialect } from "./dialect.js";
31
+ export interface McpToolIssue {
32
+ readonly tool: string;
33
+ readonly server: string;
34
+ readonly message: string;
35
+ }
36
+ /**
37
+ * The server segment of a direct `mcp__<server>__<tool>` reference, or null when
38
+ * the token isn't a direct MCP tool we resolve: a non-MCP tool, or the
39
+ * plugin-namespaced `mcp__plugin_…__…` form (guard 3 — deliberately skipped).
40
+ * A `Tool(restriction)` suffix is stripped first.
41
+ */
42
+ export declare function mcpToolServer(raw: string, dialect: HarnessDialect): string | null;
43
+ /**
44
+ * Verify the MCP tool references in a contract against the plugin's declared MCP
45
+ * servers. Returns one {@link McpToolIssue} per direct `mcp__<server>__<tool>`
46
+ * whose server is neither declared nor a known built-in. Returns `[]` when no
47
+ * servers are declared (guard 1 — we can't know the resolvable set).
48
+ */
49
+ export declare function verifyMcpToolServers(tools: readonly string[], declaredServers: readonly string[], dialect: HarnessDialect): McpToolIssue[];
50
+ //# sourceMappingURL=mcp-tool.d.ts.map
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mcpToolServer = mcpToolServer;
4
+ exports.verifyMcpToolServers = verifyMcpToolServers;
5
+ /**
6
+ * The server segment of a direct `mcp__<server>__<tool>` reference, or null when
7
+ * the token isn't a direct MCP tool we resolve: a non-MCP tool, or the
8
+ * plugin-namespaced `mcp__plugin_…__…` form (guard 3 — deliberately skipped).
9
+ * A `Tool(restriction)` suffix is stripped first.
10
+ */
11
+ function mcpToolServer(raw, dialect) {
12
+ const tool = raw.split("(")[0].trim();
13
+ if (!dialect.mcpToolPattern.test(tool))
14
+ return null;
15
+ // Non-greedy first segment after `mcp__`, up to the next `__`.
16
+ const m = /^mcp__(.+?)__/.exec(tool);
17
+ if (!m)
18
+ return null;
19
+ const server = m[1];
20
+ // Guard 3: the plugin-namespaced form references the plugin's OWN server under
21
+ // an ambiguous single-underscore join — don't try to split it, don't flag it.
22
+ if (server.startsWith("plugin_"))
23
+ return null;
24
+ return server;
25
+ }
26
+ /**
27
+ * Verify the MCP tool references in a contract against the plugin's declared MCP
28
+ * servers. Returns one {@link McpToolIssue} per direct `mcp__<server>__<tool>`
29
+ * whose server is neither declared nor a known built-in. Returns `[]` when no
30
+ * servers are declared (guard 1 — we can't know the resolvable set).
31
+ */
32
+ function verifyMcpToolServers(tools, declaredServers, dialect) {
33
+ // Guard 1: no declared set → the plugin relies on global/project servers; we
34
+ // can't tell a dead reference from a legitimate global one. Flag nothing.
35
+ if (declaredServers.length === 0)
36
+ return [];
37
+ const known = new Set([
38
+ ...declaredServers,
39
+ ...(dialect.knownMcpServers ?? []),
40
+ ]);
41
+ const issues = [];
42
+ const seen = new Set();
43
+ for (const raw of tools) {
44
+ const server = mcpToolServer(raw, dialect);
45
+ if (server === null)
46
+ continue; // not a direct MCP tool / plugin-namespaced
47
+ if (known.has(server))
48
+ continue;
49
+ const tool = raw.split("(")[0].trim();
50
+ if (seen.has(tool))
51
+ continue; // de-dupe a repeated entry
52
+ seen.add(tool);
53
+ issues.push({
54
+ tool,
55
+ server,
56
+ message: `MCP tool "${tool}" references server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the tool can't resolve.`,
57
+ });
58
+ }
59
+ return issues;
60
+ }
61
+ //# sourceMappingURL=mcp-tool.js.map