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.
- package/README.md +82 -116
- package/dist/adapters/claude-code/agent-runtime.d.ts +10 -0
- package/dist/adapters/claude-code/agent-runtime.js +15 -29
- package/dist/adapters/claude-code/dialect.js +18 -2
- package/dist/adapters/codex/eval.d.ts +94 -0
- package/dist/adapters/codex/eval.js +227 -0
- package/dist/cli.js +464 -8
- package/dist/codex.d.ts +1 -0
- package/dist/codex.js +3 -0
- package/dist/core/compile.js +8 -36
- package/dist/core/description-overlap.d.ts +27 -0
- package/dist/core/description-overlap.js +53 -0
- package/dist/core/dialect.d.ts +8 -0
- package/dist/core/frontmatter-read.d.ts +25 -0
- package/dist/core/frontmatter-read.js +138 -0
- package/dist/core/hook-events.d.ts +34 -0
- package/dist/core/hook-events.js +48 -0
- package/dist/core/mcp-config.d.ts +20 -0
- package/dist/core/mcp-config.js +40 -0
- package/dist/core/mcp-hook.d.ts +35 -0
- package/dist/core/mcp-hook.js +70 -0
- package/dist/core/mcp-tool.d.ts +50 -0
- package/dist/core/mcp-tool.js +61 -0
- package/dist/core/tool-contract.d.ts +68 -0
- package/dist/core/tool-contract.js +113 -0
- package/dist/core/types.d.ts +89 -0
- package/dist/core/validate.js +22 -0
- package/dist/eval.d.ts +69 -13
- package/dist/eval.js +106 -51
- package/dist/leaderboard.js +61 -3
- package/dist/plugin-loader.d.ts +1 -0
- package/dist/plugin-loader.js +71 -18
- package/dist/scan-behavioral.d.ts +73 -0
- package/dist/scan-behavioral.js +150 -0
- package/dist/scan.d.ts +126 -1
- package/dist/scan.js +559 -40
- package/package.json +27 -4
- 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
|
package/dist/core/dialect.d.ts
CHANGED
|
@@ -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
|