vigiles 9.1.0 → 11.0.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 +126 -112
- package/dist/adapters/claude-code/dialect.js +15 -0
- package/dist/audit-html.d.ts +15 -4
- package/dist/audit-html.js +15 -6
- package/dist/audit-report.d.ts +58 -2
- package/dist/audit-report.js +29 -0
- package/dist/audit-report.template.html +34 -24
- package/dist/audit-score.d.ts +19 -12
- package/dist/audit-score.js +79 -15
- package/dist/audit-serve.d.ts +109 -0
- package/dist/audit-serve.js +257 -0
- package/dist/cli.js +435 -20
- package/dist/core/CLAUDE.md.spec.d.ts +3 -0
- package/dist/core/CLAUDE.md.spec.js +26 -0
- package/dist/core/compile.d.ts +5 -1
- package/dist/core/compile.js +19 -10
- package/dist/core/delegation-trifecta.d.ts +64 -0
- package/dist/core/delegation-trifecta.js +124 -0
- package/dist/core/dialect.d.ts +18 -0
- package/dist/core/hook-block-ineffective.d.ts +62 -0
- package/dist/core/hook-block-ineffective.js +153 -0
- package/dist/core/hook-matcher.d.ts +66 -0
- package/dist/core/hook-matcher.js +182 -0
- package/dist/core/hook-normalize.d.ts +43 -0
- package/dist/core/hook-normalize.js +78 -0
- package/dist/core/lethal-trifecta.d.ts +100 -0
- package/dist/core/lethal-trifecta.js +197 -0
- package/dist/core/plugin-dir-layout.d.ts +30 -0
- package/dist/core/plugin-dir-layout.js +73 -0
- package/dist/core/rule-meta.d.ts +82 -0
- package/dist/core/rule-meta.js +266 -0
- package/dist/core/skill-missing-fence.d.ts +47 -0
- package/dist/core/skill-missing-fence.js +119 -0
- package/dist/core/skill-resources.d.ts +27 -0
- package/dist/core/skill-resources.js +167 -0
- package/dist/core/types.d.ts +71 -0
- package/dist/core/validate.d.ts +1 -0
- package/dist/core/validate.js +26 -4
- package/dist/leaderboard.d.ts +1 -0
- package/dist/leaderboard.js +64 -15
- package/dist/scan-behavioral.d.ts +85 -0
- package/dist/scan-behavioral.js +225 -0
- package/dist/scan.d.ts +106 -0
- package/dist/scan.js +269 -53
- package/dist/setup-plan.d.ts +6 -3
- package/dist/setup-plan.js +12 -2
- package/package.json +1 -1
package/dist/scan.js
CHANGED
|
@@ -31,12 +31,20 @@ const plugin_loader_js_2 = require("./plugin-loader.js");
|
|
|
31
31
|
const tool_contract_js_1 = require("./core/tool-contract.js");
|
|
32
32
|
const hook_events_js_1 = require("./core/hook-events.js");
|
|
33
33
|
const mcp_config_js_1 = require("./core/mcp-config.js");
|
|
34
|
+
const hook_normalize_js_1 = require("./core/hook-normalize.js");
|
|
34
35
|
const linters_js_1 = require("./core/linters.js");
|
|
35
36
|
const frontmatter_read_js_1 = require("./core/frontmatter-read.js");
|
|
36
37
|
const description_overlap_js_1 = require("./core/description-overlap.js");
|
|
37
38
|
const mcp_tool_js_1 = require("./core/mcp-tool.js");
|
|
38
39
|
const mcp_js_1 = require("./core/mcp.js");
|
|
39
40
|
const mcp_hook_js_1 = require("./core/mcp-hook.js");
|
|
41
|
+
const lethal_trifecta_js_1 = require("./core/lethal-trifecta.js");
|
|
42
|
+
const skill_resources_js_1 = require("./core/skill-resources.js");
|
|
43
|
+
const skill_missing_fence_js_1 = require("./core/skill-missing-fence.js");
|
|
44
|
+
const plugin_dir_layout_js_1 = require("./core/plugin-dir-layout.js");
|
|
45
|
+
const delegation_trifecta_js_1 = require("./core/delegation-trifecta.js");
|
|
46
|
+
const hook_block_ineffective_js_1 = require("./core/hook-block-ineffective.js");
|
|
47
|
+
const hook_matcher_js_1 = require("./core/hook-matcher.js");
|
|
40
48
|
const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
|
|
41
49
|
const test_coverage_js_1 = require("./test-coverage.js");
|
|
42
50
|
const effects_js_1 = require("./core/effects.js");
|
|
@@ -69,14 +77,24 @@ function makeClassifier(layout) {
|
|
|
69
77
|
const skillRe = skill ? new RegExp(`${skill}[^/]+/SKILL\\.md$`) : null;
|
|
70
78
|
const agentRe = agent ? new RegExp(`${agent}[^/]+\\.md$`) : null;
|
|
71
79
|
const commandRe = command ? new RegExp(`${command}.+\\.md$`) : null;
|
|
72
|
-
// A subagent lives at the plugin's TOP-LEVEL `agents/` dir
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
// A subagent lives at the plugin's TOP-LEVEL `agents/` dir (e.g. `agents/foo.md`
|
|
81
|
+
// or `.claude/agents/foo.md`), never recursively under ANOTHER surface dir. Two
|
|
82
|
+
// real-world nesting traps are excluded as false positives:
|
|
83
|
+
// - `skills/<x>/agents/…` — skill-internal worker docs (Anthropic's skill-creator)
|
|
84
|
+
// - `commands/agents/…` — a COMMAND namespaced `/agents:…` (ruvnet/claude-flow),
|
|
85
|
+
// incl. a `README.md`; these are commands, not dispatchable subagents.
|
|
86
|
+
// Flagging either as a subagent missing frontmatter is a false positive (it
|
|
87
|
+
// mis-graded a real plugin F). A genuine top-level `agents/foo.md` still
|
|
88
|
+
// matches. Both excluded dirs are read from the layout (adapter-agnostic). See
|
|
89
|
+
// scan.test.ts for the regressions.
|
|
90
|
+
const nestedUnder = [
|
|
91
|
+
layout.skillDir &&
|
|
92
|
+
`${escapeRe(layout.skillDir)}/.+/${escapeRe(layout.agentDir)}/`,
|
|
93
|
+
layout.commandDir &&
|
|
94
|
+
`${escapeRe(layout.commandDir)}/(?:.+/)?${escapeRe(layout.agentDir)}/`,
|
|
95
|
+
].filter((x) => Boolean(x));
|
|
96
|
+
const nestedAgentRe = layout.agentDir && nestedUnder.length
|
|
97
|
+
? new RegExp(`(?:^|/)(?:${nestedUnder.join("|")})`)
|
|
80
98
|
: null;
|
|
81
99
|
const isAgent = (f) => (agentRe?.test(f) ?? false) &&
|
|
82
100
|
!f.endsWith(".spec.ts") &&
|
|
@@ -165,7 +183,27 @@ function firstBodyParagraph(md) {
|
|
|
165
183
|
}
|
|
166
184
|
return para.join(" ").trim() || undefined;
|
|
167
185
|
}
|
|
168
|
-
|
|
186
|
+
/** The body of a SKILL.md with the leading `---` frontmatter block stripped. */
|
|
187
|
+
function skillBody(md) {
|
|
188
|
+
return md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The on-disk path for a materialized file key. `loadPlugin` prefixes each
|
|
192
|
+
* surface file with the layout's `materializeRoot` (e.g. `.claude/`), but the file
|
|
193
|
+
* lives on disk WITHOUT that prefix (under the real surface dir), so a
|
|
194
|
+
* bundled-resource existence check must strip it back off. Mirrors how
|
|
195
|
+
* `resolveScript` resolves a hook path against the real plugin root.
|
|
196
|
+
*/
|
|
197
|
+
function onDiskPath(materializedKey, materializeRoot) {
|
|
198
|
+
if (!materializeRoot)
|
|
199
|
+
return materializedKey;
|
|
200
|
+
const prefix = `${materializeRoot}/`;
|
|
201
|
+
return materializedKey.startsWith(prefix)
|
|
202
|
+
? materializedKey.slice(prefix.length)
|
|
203
|
+
: materializedKey;
|
|
204
|
+
}
|
|
205
|
+
function scanSkills(files, cls, ctx) {
|
|
206
|
+
const { root, materializeRoot, dialect } = ctx;
|
|
169
207
|
const out = [];
|
|
170
208
|
for (const [path, md] of Object.entries(files)) {
|
|
171
209
|
if (!cls.isSkill(path))
|
|
@@ -176,13 +214,35 @@ function scanSkills(files, cls) {
|
|
|
176
214
|
// NEITHER exists is the skill genuinely undescribed (can't be selected). The
|
|
177
215
|
// explicit-frontmatter best-practice is the separate `skill-frontmatter` rule.
|
|
178
216
|
const effectiveDesc = fm.description ?? firstBodyParagraph(md);
|
|
217
|
+
const userInvoked = /^\s*disable-model-invocation:\s*true\s*$/m.test(md);
|
|
218
|
+
// Bundled-resource refs resolve against the skill's OWN dir (resources ship
|
|
219
|
+
// beside the SKILL.md), built from the plugin root + the file's ON-DISK dir
|
|
220
|
+
// (the materialize-root prefix the loader added is stripped back off).
|
|
221
|
+
const skillDir = (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
|
|
222
|
+
const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir);
|
|
223
|
+
// The lethal trifecta is a property of what a unit CAN do, which for a skill is
|
|
224
|
+
// its declared `allowed-tools` (the CC skill tool contract). Only a model-
|
|
225
|
+
// invocable skill can be hijacked by attacker content, so a user-invoked one is
|
|
226
|
+
// excluded. A skill with no `allowed-tools` line inherits all → advisory.
|
|
227
|
+
const skillTools = (0, agent_runtime_js_1.parseAgentToolList)(md, "allowed-tools");
|
|
228
|
+
// No `allowed-tools:` line (null) → inherits all → wildcard sentinel; an
|
|
229
|
+
// EXPLICIT empty `[]` means zero tools → no trifecta (don't collapse them).
|
|
230
|
+
const trifecta = userInvoked
|
|
231
|
+
? null
|
|
232
|
+
: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(skillTools ?? ["*"], dialect);
|
|
179
233
|
out.push({
|
|
180
234
|
name: fm.name ?? skillName(path),
|
|
181
235
|
path,
|
|
182
236
|
hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
|
|
183
237
|
description: effectiveDesc?.trim(),
|
|
184
|
-
userInvoked
|
|
238
|
+
userInvoked,
|
|
185
239
|
descriptionScript: effectiveDesc ? unexpectedScript(effectiveDesc) : null,
|
|
240
|
+
resourceIssues,
|
|
241
|
+
trifecta,
|
|
242
|
+
// A SKILL.md opening with `name:`/`description:` but no `---` fence loads
|
|
243
|
+
// as plain body → the skill is invisible. Inspect the RAW md (not the
|
|
244
|
+
// frontmatter-stripped body) so the unfenced keys are visible.
|
|
245
|
+
fenceIssue: (0, skill_missing_fence_js_1.skillMissingFence)(md),
|
|
186
246
|
});
|
|
187
247
|
}
|
|
188
248
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -245,6 +305,11 @@ function scanAgents(files, dialect, declaredServers, cls) {
|
|
|
245
305
|
sideEffecting: surface.sideEffecting,
|
|
246
306
|
unknown: surface.unknown,
|
|
247
307
|
},
|
|
308
|
+
// The lethal trifecta: a subagent whose contract grants all three legs. An
|
|
309
|
+
// inherits-all agent (no `tools:` line → tools === null) is the advisory
|
|
310
|
+
// case — pass the wildcard sentinel so it's distinguished from an EXPLICIT
|
|
311
|
+
// empty `tools: []` (zero tools → no trifecta). One detector, no drift.
|
|
312
|
+
trifecta: (0, lethal_trifecta_js_1.lethalTrifectaIssues)(tools ?? ["*"], dialect),
|
|
248
313
|
});
|
|
249
314
|
}
|
|
250
315
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -314,57 +379,41 @@ function preferCompiledHooksMessage(count) {
|
|
|
314
379
|
* `SessionStart`/`PostToolUse`/`Stop` hook isn't tested against the disaster
|
|
315
380
|
* catalog. Returns an empty map for a non-object/array config (event → unknown).
|
|
316
381
|
*/
|
|
317
|
-
function eventsByScript(
|
|
382
|
+
function eventsByScript(regs) {
|
|
318
383
|
const map = new Map();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
continue;
|
|
324
|
-
for (const entry of arr) {
|
|
325
|
-
const hookList = entry.hooks;
|
|
326
|
-
if (!Array.isArray(hookList))
|
|
327
|
-
continue;
|
|
328
|
-
for (const h of hookList) {
|
|
329
|
-
const cmd = h.command;
|
|
330
|
-
if (typeof cmd !== "string")
|
|
331
|
-
continue;
|
|
332
|
-
for (const tok of cmd.match(SCRIPT_RE) ?? []) {
|
|
333
|
-
if (!map.has(tok))
|
|
334
|
-
map.set(tok, event);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
384
|
+
for (const reg of regs) {
|
|
385
|
+
for (const tok of reg.command.match(SCRIPT_RE) ?? []) {
|
|
386
|
+
if (!map.has(tok))
|
|
387
|
+
map.set(tok, reg.event);
|
|
337
388
|
}
|
|
338
389
|
}
|
|
339
390
|
return map;
|
|
340
391
|
}
|
|
341
|
-
function scanHooks(
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
const evMap = eventsByScript(settings.hooks);
|
|
392
|
+
function scanHooks(regs, root, pluginRootToken) {
|
|
393
|
+
const commands = regs.map((r) => r.command);
|
|
394
|
+
const evMap = eventsByScript(regs);
|
|
345
395
|
// A hand-written hook is any non-empty command that isn't a vigiles-managed
|
|
346
396
|
// (compiled) hook-runtime invocation — the basis for the prefer-compiled-hooks nudge.
|
|
347
397
|
const manual = commands.filter((c) => {
|
|
348
|
-
const u = c.
|
|
398
|
+
const u = c.trim();
|
|
349
399
|
return u !== "" && !isManagedHookCommand(u);
|
|
350
400
|
}).length;
|
|
351
401
|
const byScript = new Map();
|
|
352
402
|
let inline = 0;
|
|
353
403
|
for (const cmd of commands) {
|
|
354
|
-
const
|
|
355
|
-
const found = unescaped.match(SCRIPT_RE);
|
|
404
|
+
const found = cmd.match(SCRIPT_RE);
|
|
356
405
|
if (!found || found.length === 0) {
|
|
357
406
|
inline++;
|
|
358
407
|
continue;
|
|
359
408
|
}
|
|
360
409
|
// A guarded command runs its script only if it exists — an optional hook, not
|
|
361
410
|
// a broken one. Treat it as a conditional one-liner (inline), don't path-check.
|
|
362
|
-
if (EXISTENCE_GUARD.test(
|
|
411
|
+
if (EXISTENCE_GUARD.test(cmd)) {
|
|
363
412
|
inline++;
|
|
364
413
|
continue;
|
|
365
414
|
}
|
|
366
415
|
for (const tok of found) {
|
|
367
|
-
const hook = resolveScript(tok, root, pluginRootToken,
|
|
416
|
+
const hook = resolveScript(tok, root, pluginRootToken, cmd);
|
|
368
417
|
const event = evMap.get(tok);
|
|
369
418
|
byScript.set(hook.script, event ? { ...hook, event } : hook);
|
|
370
419
|
}
|
|
@@ -562,24 +611,157 @@ function collectMcpServers(root, layout) {
|
|
|
562
611
|
collect(layout.manifestPath);
|
|
563
612
|
return servers;
|
|
564
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Flatten the per-surface lethal-trifecta + skill-resource findings into the
|
|
616
|
+
* path-tagged report lists the `audit` report AND the `lethal-trifecta` /
|
|
617
|
+
* `skill-resource-resolves` lint rules both consume (one detector, no drift).
|
|
618
|
+
*/
|
|
619
|
+
function collectSurfaceFindings(agents, skills) {
|
|
620
|
+
const trifectaFindings = [];
|
|
621
|
+
for (const a of agents) {
|
|
622
|
+
if (a.trifecta) {
|
|
623
|
+
trifectaFindings.push({
|
|
624
|
+
path: a.path,
|
|
625
|
+
kind: "subagent",
|
|
626
|
+
name: a.name,
|
|
627
|
+
finding: a.trifecta,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const s of skills) {
|
|
632
|
+
if (s.trifecta) {
|
|
633
|
+
trifectaFindings.push({
|
|
634
|
+
path: s.path,
|
|
635
|
+
kind: "skill",
|
|
636
|
+
name: s.name,
|
|
637
|
+
finding: s.trifecta,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
const skillResourceFindings = skills.flatMap((s) => s.resourceIssues.map((finding) => ({
|
|
642
|
+
path: s.path,
|
|
643
|
+
name: s.name,
|
|
644
|
+
finding,
|
|
645
|
+
})));
|
|
646
|
+
const skillFenceFindings = skills.flatMap((s) => s.fenceIssue ? [{ path: s.path, name: s.name, finding: s.fenceIssue }] : []);
|
|
647
|
+
return { trifectaFindings, skillResourceFindings, skillFenceFindings };
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Build the subagent delegation graph and flag a lethal trifecta that EMERGES
|
|
651
|
+
* across an edge (own ∪ delegated-to capability) though no single unit trips it.
|
|
652
|
+
*
|
|
653
|
+
* Edge source (deterministic, audit-available): a subagent that lists the `Task`
|
|
654
|
+
* tool can dispatch any sibling subagent, so it `delegatesTo` every OTHER agent.
|
|
655
|
+
* An inherits-all agent (`tools === null`) carries the wildcard, which the
|
|
656
|
+
* detector's FP-safe guard skips (that maximal-blast case is the per-unit
|
|
657
|
+
* advisory's job). One detector, no drift. (Richer edge sources — a typed
|
|
658
|
+
* railway's `delegate()` chain, a Flue subagent inheritance tree — plug in here.)
|
|
659
|
+
*/
|
|
660
|
+
function collectDelegationTrifecta(agents, dialect) {
|
|
661
|
+
const allNames = agents.map((a) => a.name);
|
|
662
|
+
const nodes = agents.map((a) => {
|
|
663
|
+
const canDispatch = a.tools === null ||
|
|
664
|
+
a.tools.some((t) => t === "Task" || t.startsWith("Task("));
|
|
665
|
+
return {
|
|
666
|
+
name: a.name,
|
|
667
|
+
kind: "agent",
|
|
668
|
+
tools: a.tools ?? ["*"],
|
|
669
|
+
delegatesTo: canDispatch ? allNames.filter((n) => n !== a.name) : [],
|
|
670
|
+
};
|
|
671
|
+
});
|
|
672
|
+
const pathByName = new Map(agents.map((a) => [a.name, a.path]));
|
|
673
|
+
return (0, delegation_trifecta_js_1.delegationTrifectaIssues)(nodes, dialect).map((finding) => ({
|
|
674
|
+
path: pathByName.get(finding.name) ?? "",
|
|
675
|
+
finding,
|
|
676
|
+
}));
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Build `hookBlockIssues` entries by walking the canonical object-keyed-by-event
|
|
680
|
+
* settings shape PER REGISTRATION — so a script registered under several events
|
|
681
|
+
* is inspected under EACH (no de-dup by script path), inline one-liners are
|
|
682
|
+
* included (script token → null, inspect the command), and a script token is
|
|
683
|
+
* resolved to its ABSOLUTE on-disk path against the plugin root (not the caller's
|
|
684
|
+
* cwd). Addresses the gaps a de-duplicated `ScanHook[]` would miss.
|
|
685
|
+
*/
|
|
686
|
+
function collectHookBlockEntries(regs, root, pluginRootToken) {
|
|
687
|
+
const out = [];
|
|
688
|
+
for (const { event, command: cmd } of regs) {
|
|
689
|
+
// A wrapper command runs MORE than one script (`node run.cjs guard.mjs`),
|
|
690
|
+
// so resolve EVERY candidate and inspect each — reading only the first
|
|
691
|
+
// (the wrapper) would miss the guard's block logic. Candidates: extensioned
|
|
692
|
+
// script tokens (SCRIPT_RE) PLUS path-like words with NO extension
|
|
693
|
+
// (`bash hooks/guard`, `${ROOT}/hooks/session-start`) that resolve to a file.
|
|
694
|
+
const candidates = new Set(cmd.match(SCRIPT_RE) ?? []);
|
|
695
|
+
for (const word of cmd.split(/\s+/)) {
|
|
696
|
+
const w = word.replace(/^["']+|["']+$/g, "");
|
|
697
|
+
if (w.startsWith("-"))
|
|
698
|
+
continue; // a flag, not a path
|
|
699
|
+
if (w.includes("/") || w.includes(pluginRootToken))
|
|
700
|
+
candidates.add(w);
|
|
701
|
+
}
|
|
702
|
+
const resolvedPaths = [];
|
|
703
|
+
for (const tok of candidates) {
|
|
704
|
+
const r = resolveScript(tok, root, pluginRootToken, cmd);
|
|
705
|
+
if (r.status === "ok") {
|
|
706
|
+
const abs = (0, node_path_1.isAbsolute)(r.script) ? r.script : (0, node_path_1.resolve)(root, r.script);
|
|
707
|
+
if (!resolvedPaths.includes(abs))
|
|
708
|
+
resolvedPaths.push(abs);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (resolvedPaths.length > 0) {
|
|
712
|
+
// One entry per resolvable script (each is inspected on its own).
|
|
713
|
+
for (const sp of resolvedPaths) {
|
|
714
|
+
out.push({ event, command: cmd, scriptPath: sp });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
// No script file resolved → inline one-liner; inspect the command text.
|
|
719
|
+
out.push({ event, command: cmd, scriptPath: null });
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
/** Extract (event, matcher) pairs from the normalized hook registrations. */
|
|
725
|
+
function collectHookMatchers(regs) {
|
|
726
|
+
const seen = new Set();
|
|
727
|
+
const out = [];
|
|
728
|
+
for (const { event, matcher } of regs) {
|
|
729
|
+
if (matcher === null)
|
|
730
|
+
continue;
|
|
731
|
+
const key = `${event}${matcher}`;
|
|
732
|
+
if (seen.has(key))
|
|
733
|
+
continue;
|
|
734
|
+
seen.add(key);
|
|
735
|
+
out.push({ event, matcher });
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
/** Tally how many scanned agents fall into each purity rung (effectSurface). */
|
|
740
|
+
function summarizePurity(agents) {
|
|
741
|
+
return agents.reduce((acc, a) => {
|
|
742
|
+
acc[a.purity]++;
|
|
743
|
+
return acc;
|
|
744
|
+
}, { pure: 0, bounded: 0, unrestricted: 0 });
|
|
745
|
+
}
|
|
565
746
|
/** Scan a plugin/repo directory and report its surfaces + structural issues. */
|
|
566
747
|
function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
|
|
567
748
|
const lay = layout ?? layout_js_1.claudeCodeLayout;
|
|
568
749
|
const cls = makeClassifier(lay);
|
|
569
750
|
const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
|
|
570
|
-
|
|
751
|
+
// Parse the raw `settings.hooks` ONCE at the boundary (parse-don't-validate):
|
|
752
|
+
// tolerant of the Claude Code nested shape AND the Codex flat shape, so every
|
|
753
|
+
// hook detector below consumes typed `HookRegistration[]` instead of re-walking
|
|
754
|
+
// `unknown`. The single seam where the per-harness hook shape is absorbed.
|
|
755
|
+
const hookRegs = (0, hook_normalize_js_1.normalizeHooks)(loaded.settings.hooks);
|
|
756
|
+
const { hooks, inline, manual } = scanHooks(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken);
|
|
571
757
|
// Hook-event keys are a CLOSED platform set — an unrecognized one is a dead
|
|
572
758
|
// registration (the hook never fires), so flag every unknown (not just typos).
|
|
573
759
|
// ONLY for the canonical object-keyed-by-event shape: a plugin shipping a
|
|
574
760
|
// hooks ARRAY uses a non-CC/custom format whose events live INSIDE each entry
|
|
575
|
-
// (e.g. ananddtyagi/sugar's `[{event:"tool-use",…}]`) —
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
const eventNames =
|
|
579
|
-
typeof hooksObj === "object" &&
|
|
580
|
-
!Array.isArray(hooksObj)
|
|
581
|
-
? Object.keys(hooksObj)
|
|
582
|
-
: [];
|
|
761
|
+
// (e.g. ananddtyagi/sugar's `[{event:"tool-use",…}]`) — `hookEventNames` reads
|
|
762
|
+
// object keys only and returns [] for an array. We don't interpret a format we
|
|
763
|
+
// don't own.
|
|
764
|
+
const eventNames = (0, hook_normalize_js_1.hookEventNames)(loaded.settings.hooks);
|
|
583
765
|
const hookEventIssues = (0, hook_events_js_1.confidentHookEventIssues)((0, hook_events_js_1.verifyHookEvents)(eventNames, dialect));
|
|
584
766
|
const instructions = loaded.files[lay.instructionFile] !== undefined
|
|
585
767
|
? {
|
|
@@ -590,14 +772,17 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
|
|
|
590
772
|
const mcpServers = collectMcpServers((0, node_path_1.resolve)(dir), lay);
|
|
591
773
|
const declaredServers = Object.keys(mcpServers);
|
|
592
774
|
const agents = scanAgents(loaded.files, dialect, declaredServers, cls);
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
775
|
+
const skills = scanSkills(loaded.files, cls, {
|
|
776
|
+
root: (0, node_path_1.resolve)(dir),
|
|
777
|
+
materializeRoot: lay.materializeRoot,
|
|
778
|
+
dialect,
|
|
779
|
+
});
|
|
780
|
+
const puritySummary = summarizePurity(agents);
|
|
781
|
+
const { trifectaFindings, skillResourceFindings, skillFenceFindings } = collectSurfaceFindings(agents, skills);
|
|
597
782
|
return {
|
|
598
783
|
dir,
|
|
599
784
|
instructions,
|
|
600
|
-
skills
|
|
785
|
+
skills,
|
|
601
786
|
agents,
|
|
602
787
|
hooks,
|
|
603
788
|
inlineHooks: inline,
|
|
@@ -612,6 +797,22 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
|
|
|
612
797
|
mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
|
|
613
798
|
mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
|
|
614
799
|
descriptionOverlaps: descriptionOverlapsFor(loaded.files, cls),
|
|
800
|
+
trifectaFindings,
|
|
801
|
+
skillResourceIssues: skillResourceFindings,
|
|
802
|
+
skillFenceIssues: skillFenceFindings,
|
|
803
|
+
pluginLayoutIssues: (0, plugin_dir_layout_js_1.pluginDirLayoutIssues)((0, node_path_1.resolve)(dir, (0, node_path_1.dirname)(lay.manifestPath)),
|
|
804
|
+
// The hooks dir is a misplaceable functional surface too, but it lives in
|
|
805
|
+
// the layout as a convention PATH (`hooks/hooks.json`), not in surfaceDirs
|
|
806
|
+
// — derive its first segment and dedupe so the detector watches it as well.
|
|
807
|
+
[...new Set([...lay.surfaceDirs, lay.hooksConventionPath.split("/")[0]])]),
|
|
808
|
+
delegationTrifecta: collectDelegationTrifecta(agents, dialect),
|
|
809
|
+
hookBlockFindings: dialect.noEffectHookEvents
|
|
810
|
+
? (0, hook_block_ineffective_js_1.hookBlockIssues)(collectHookBlockEntries(hookRegs, (0, node_path_1.resolve)(dir), lay.pluginRootToken), {
|
|
811
|
+
noEffectEvents: new Set(dialect.noEffectHookEvents),
|
|
812
|
+
permissionDecisionEvents: new Set(dialect.permissionDecisionHookEvents ?? []),
|
|
813
|
+
})
|
|
814
|
+
: [],
|
|
815
|
+
hookMatcherFindings: (0, hook_matcher_js_1.hookMatcherIssues)(collectHookMatchers(hookRegs), declaredServers, dialect),
|
|
615
816
|
malformedFrontmatter: malformedFrontmatterFor(loaded.files, cls),
|
|
616
817
|
warnings: loaded.warnings,
|
|
617
818
|
untested: (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir, layout: lay }).untested
|
|
@@ -801,6 +1002,13 @@ function formatScanReport(r) {
|
|
|
801
1002
|
out.push(...section("MCP config", r.mcpIssues.map((i) => ` ✗ ${i.message}`)));
|
|
802
1003
|
out.push(...section("MCP hook targets", r.mcpHookIssues.map((i) => ` ✗ ${i.message}`)));
|
|
803
1004
|
out.push(...section("Description overlap (precision risk)", r.descriptionOverlaps.map((o) => ` ⚠ ${o.message}`)));
|
|
1005
|
+
out.push(...section("Lethal trifecta (prompt-injection exfil risk)", r.trifectaFindings.map((t) => ` ${t.finding.severity === "hard" ? "✗" : "⚠"} ${t.kind} ${t.name} (${t.path}): ${t.finding.message}`)));
|
|
1006
|
+
out.push(...section("Skill bundled resources", r.skillResourceIssues.map((s) => ` ✗ ${s.name}: ${s.finding.ref} (line ${String(s.finding.line)}) — bundled resource not found`)));
|
|
1007
|
+
out.push(...section("Invisible skills (missing frontmatter fence)", r.skillFenceIssues.map((s) => ` ✗ ${s.name} (${s.path}): opens with \`${s.finding.key}:\` but no \`---\` fence — loads as body, never fires`)));
|
|
1008
|
+
out.push(...section("Misplaced plugin directories", r.pluginLayoutIssues.map((p) => ` ✗ ${p.message}`)));
|
|
1009
|
+
out.push(...section("Lethal trifecta across delegation (blast radius)", r.delegationTrifecta.map((d) => ` ⚠ ${d.finding.name} (${d.path}): ${d.finding.message}`)));
|
|
1010
|
+
out.push(...section("Ineffective hook guards (false confidence)", r.hookBlockFindings.map((h) => ` ✗ [${h.event}] ${h.scriptPath ?? "(inline)"}: ${h.message}`)));
|
|
1011
|
+
out.push(...section("Hook matchers that never fire", r.hookMatcherFindings.map((m) => ` ✗ ${m.message}`)));
|
|
804
1012
|
const facts = [];
|
|
805
1013
|
if (r.commands > 0)
|
|
806
1014
|
facts.push(`Commands: ${String(r.commands)}`);
|
|
@@ -855,7 +1063,15 @@ function formatScanReport(r) {
|
|
|
855
1063
|
r.frontmatterIssues.length +
|
|
856
1064
|
r.frontmatterValueIssues.length +
|
|
857
1065
|
r.mcpIssues.length +
|
|
858
|
-
r.mcpHookIssues.length
|
|
1066
|
+
r.mcpHookIssues.length +
|
|
1067
|
+
r.skillResourceIssues.length +
|
|
1068
|
+
r.skillFenceIssues.length +
|
|
1069
|
+
r.pluginLayoutIssues.length +
|
|
1070
|
+
r.hookBlockFindings.length +
|
|
1071
|
+
r.hookMatcherFindings.length +
|
|
1072
|
+
// HARD trifectas render ✗ and are graded into the score, so they count; the
|
|
1073
|
+
// ADVISORY (inherits-all) ones and the delegation-trifecta ⚠ risk do NOT.
|
|
1074
|
+
r.trifectaFindings.filter((t) => t.finding.severity === "hard").length;
|
|
859
1075
|
out.push(broken === 0
|
|
860
1076
|
? "✓ no structural issues found"
|
|
861
1077
|
: `⚠ ${String(broken)} structural issue(s) — see ✗/⚠ above`);
|
package/dist/setup-plan.d.ts
CHANGED
|
@@ -89,10 +89,13 @@ export declare const WORKFLOW_RULES: readonly ["require-instructions-spec", "unt
|
|
|
89
89
|
* CC's loader), `skill-frontmatter` (skills load without it) and `unmarked-refs`
|
|
90
90
|
* (the undecidable-plaintext nudge) sit at `warn`; `prefer-compiled-hooks` defaults
|
|
91
91
|
* OFF (a recommendation that shouldn't fire unasked — the shell lane stays
|
|
92
|
-
* first-class). `
|
|
93
|
-
*
|
|
92
|
+
* first-class). `lethal-trifecta` + `skill-resource-resolves` are here for now on a
|
|
93
|
+
* don't-cry-wolf rollout (default `warn`; a team raises either to `error` by hand
|
|
94
|
+
* once it's confirmed quiet on their corpus). `init` does not write these — they
|
|
95
|
+
* keep their own default severities. Named for the group taxonomy
|
|
96
|
+
* (research/install-enforcement-dx.md).
|
|
94
97
|
*/
|
|
95
|
-
export declare const NUDGE_RULES: readonly ["frontmatter-valid", "skill-frontmatter", "prefer-compiled-hooks", "unmarked-refs"];
|
|
98
|
+
export declare const NUDGE_RULES: readonly ["frontmatter-valid", "skill-frontmatter", "prefer-compiled-hooks", "unmarked-refs", "lethal-trifecta", "skill-resource-resolves", "skill-missing-fence", "plugin-dir-layout", "delegation-trifecta", "hook-block-ineffective", "hook-matcher"];
|
|
96
99
|
export declare function mergeProjectConfig(existing: Record<string, unknown>, opts: {
|
|
97
100
|
harness: string | string[];
|
|
98
101
|
strict: boolean;
|
package/dist/setup-plan.js
CHANGED
|
@@ -112,14 +112,24 @@ exports.WORKFLOW_RULES = [
|
|
|
112
112
|
* CC's loader), `skill-frontmatter` (skills load without it) and `unmarked-refs`
|
|
113
113
|
* (the undecidable-plaintext nudge) sit at `warn`; `prefer-compiled-hooks` defaults
|
|
114
114
|
* OFF (a recommendation that shouldn't fire unasked — the shell lane stays
|
|
115
|
-
* first-class). `
|
|
116
|
-
*
|
|
115
|
+
* first-class). `lethal-trifecta` + `skill-resource-resolves` are here for now on a
|
|
116
|
+
* don't-cry-wolf rollout (default `warn`; a team raises either to `error` by hand
|
|
117
|
+
* once it's confirmed quiet on their corpus). `init` does not write these — they
|
|
118
|
+
* keep their own default severities. Named for the group taxonomy
|
|
119
|
+
* (research/install-enforcement-dx.md).
|
|
117
120
|
*/
|
|
118
121
|
exports.NUDGE_RULES = [
|
|
119
122
|
"frontmatter-valid",
|
|
120
123
|
"skill-frontmatter",
|
|
121
124
|
"prefer-compiled-hooks",
|
|
122
125
|
"unmarked-refs",
|
|
126
|
+
"lethal-trifecta",
|
|
127
|
+
"skill-resource-resolves",
|
|
128
|
+
"skill-missing-fence",
|
|
129
|
+
"plugin-dir-layout",
|
|
130
|
+
"delegation-trifecta",
|
|
131
|
+
"hook-block-ineffective",
|
|
132
|
+
"hook-matcher",
|
|
123
133
|
];
|
|
124
134
|
function mergeProjectConfig(existing, opts) {
|
|
125
135
|
const config = { ...existing };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.0",
|
|
4
4
|
"description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|