vigiles 10.0.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 +113 -83
- package/dist/adapters/claude-code/dialect.js +15 -0
- package/dist/audit-report.d.ts +1 -1
- package/dist/audit-report.template.html +1 -1
- package/dist/audit-score.d.ts +19 -12
- package/dist/audit-score.js +65 -11
- package/dist/cli.js +249 -0
- package/dist/core/CLAUDE.md.spec.d.ts +3 -0
- package/dist/core/CLAUDE.md.spec.js +26 -0
- 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 +42 -4
- package/dist/scan.d.ts +106 -0
- package/dist/scan.js +251 -45
- 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");
|
|
@@ -175,7 +183,27 @@ function firstBodyParagraph(md) {
|
|
|
175
183
|
}
|
|
176
184
|
return para.join(" ").trim() || undefined;
|
|
177
185
|
}
|
|
178
|
-
|
|
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;
|
|
179
207
|
const out = [];
|
|
180
208
|
for (const [path, md] of Object.entries(files)) {
|
|
181
209
|
if (!cls.isSkill(path))
|
|
@@ -186,13 +214,35 @@ function scanSkills(files, cls) {
|
|
|
186
214
|
// NEITHER exists is the skill genuinely undescribed (can't be selected). The
|
|
187
215
|
// explicit-frontmatter best-practice is the separate `skill-frontmatter` rule.
|
|
188
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);
|
|
189
233
|
out.push({
|
|
190
234
|
name: fm.name ?? skillName(path),
|
|
191
235
|
path,
|
|
192
236
|
hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
|
|
193
237
|
description: effectiveDesc?.trim(),
|
|
194
|
-
userInvoked
|
|
238
|
+
userInvoked,
|
|
195
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),
|
|
196
246
|
});
|
|
197
247
|
}
|
|
198
248
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -255,6 +305,11 @@ function scanAgents(files, dialect, declaredServers, cls) {
|
|
|
255
305
|
sideEffecting: surface.sideEffecting,
|
|
256
306
|
unknown: surface.unknown,
|
|
257
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),
|
|
258
313
|
});
|
|
259
314
|
}
|
|
260
315
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -324,57 +379,41 @@ function preferCompiledHooksMessage(count) {
|
|
|
324
379
|
* `SessionStart`/`PostToolUse`/`Stop` hook isn't tested against the disaster
|
|
325
380
|
* catalog. Returns an empty map for a non-object/array config (event → unknown).
|
|
326
381
|
*/
|
|
327
|
-
function eventsByScript(
|
|
382
|
+
function eventsByScript(regs) {
|
|
328
383
|
const map = new Map();
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
continue;
|
|
334
|
-
for (const entry of arr) {
|
|
335
|
-
const hookList = entry.hooks;
|
|
336
|
-
if (!Array.isArray(hookList))
|
|
337
|
-
continue;
|
|
338
|
-
for (const h of hookList) {
|
|
339
|
-
const cmd = h.command;
|
|
340
|
-
if (typeof cmd !== "string")
|
|
341
|
-
continue;
|
|
342
|
-
for (const tok of cmd.match(SCRIPT_RE) ?? []) {
|
|
343
|
-
if (!map.has(tok))
|
|
344
|
-
map.set(tok, event);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
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);
|
|
347
388
|
}
|
|
348
389
|
}
|
|
349
390
|
return map;
|
|
350
391
|
}
|
|
351
|
-
function scanHooks(
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
const evMap = eventsByScript(settings.hooks);
|
|
392
|
+
function scanHooks(regs, root, pluginRootToken) {
|
|
393
|
+
const commands = regs.map((r) => r.command);
|
|
394
|
+
const evMap = eventsByScript(regs);
|
|
355
395
|
// A hand-written hook is any non-empty command that isn't a vigiles-managed
|
|
356
396
|
// (compiled) hook-runtime invocation — the basis for the prefer-compiled-hooks nudge.
|
|
357
397
|
const manual = commands.filter((c) => {
|
|
358
|
-
const u = c.
|
|
398
|
+
const u = c.trim();
|
|
359
399
|
return u !== "" && !isManagedHookCommand(u);
|
|
360
400
|
}).length;
|
|
361
401
|
const byScript = new Map();
|
|
362
402
|
let inline = 0;
|
|
363
403
|
for (const cmd of commands) {
|
|
364
|
-
const
|
|
365
|
-
const found = unescaped.match(SCRIPT_RE);
|
|
404
|
+
const found = cmd.match(SCRIPT_RE);
|
|
366
405
|
if (!found || found.length === 0) {
|
|
367
406
|
inline++;
|
|
368
407
|
continue;
|
|
369
408
|
}
|
|
370
409
|
// A guarded command runs its script only if it exists — an optional hook, not
|
|
371
410
|
// a broken one. Treat it as a conditional one-liner (inline), don't path-check.
|
|
372
|
-
if (EXISTENCE_GUARD.test(
|
|
411
|
+
if (EXISTENCE_GUARD.test(cmd)) {
|
|
373
412
|
inline++;
|
|
374
413
|
continue;
|
|
375
414
|
}
|
|
376
415
|
for (const tok of found) {
|
|
377
|
-
const hook = resolveScript(tok, root, pluginRootToken,
|
|
416
|
+
const hook = resolveScript(tok, root, pluginRootToken, cmd);
|
|
378
417
|
const event = evMap.get(tok);
|
|
379
418
|
byScript.set(hook.script, event ? { ...hook, event } : hook);
|
|
380
419
|
}
|
|
@@ -572,24 +611,157 @@ function collectMcpServers(root, layout) {
|
|
|
572
611
|
collect(layout.manifestPath);
|
|
573
612
|
return servers;
|
|
574
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
|
+
}
|
|
575
746
|
/** Scan a plugin/repo directory and report its surfaces + structural issues. */
|
|
576
747
|
function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
|
|
577
748
|
const lay = layout ?? layout_js_1.claudeCodeLayout;
|
|
578
749
|
const cls = makeClassifier(lay);
|
|
579
750
|
const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
|
|
580
|
-
|
|
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);
|
|
581
757
|
// Hook-event keys are a CLOSED platform set — an unrecognized one is a dead
|
|
582
758
|
// registration (the hook never fires), so flag every unknown (not just typos).
|
|
583
759
|
// ONLY for the canonical object-keyed-by-event shape: a plugin shipping a
|
|
584
760
|
// hooks ARRAY uses a non-CC/custom format whose events live INSIDE each entry
|
|
585
|
-
// (e.g. ananddtyagi/sugar's `[{event:"tool-use",…}]`) —
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
const eventNames =
|
|
589
|
-
typeof hooksObj === "object" &&
|
|
590
|
-
!Array.isArray(hooksObj)
|
|
591
|
-
? Object.keys(hooksObj)
|
|
592
|
-
: [];
|
|
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);
|
|
593
765
|
const hookEventIssues = (0, hook_events_js_1.confidentHookEventIssues)((0, hook_events_js_1.verifyHookEvents)(eventNames, dialect));
|
|
594
766
|
const instructions = loaded.files[lay.instructionFile] !== undefined
|
|
595
767
|
? {
|
|
@@ -600,14 +772,17 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
|
|
|
600
772
|
const mcpServers = collectMcpServers((0, node_path_1.resolve)(dir), lay);
|
|
601
773
|
const declaredServers = Object.keys(mcpServers);
|
|
602
774
|
const agents = scanAgents(loaded.files, dialect, declaredServers, cls);
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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);
|
|
607
782
|
return {
|
|
608
783
|
dir,
|
|
609
784
|
instructions,
|
|
610
|
-
skills
|
|
785
|
+
skills,
|
|
611
786
|
agents,
|
|
612
787
|
hooks,
|
|
613
788
|
inlineHooks: inline,
|
|
@@ -622,6 +797,22 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
|
|
|
622
797
|
mcpIssues: (0, mcp_config_js_1.verifyMcpServers)(mcpServers),
|
|
623
798
|
mcpHookIssues: (0, mcp_hook_js_1.verifyMcpHookTargets)(loaded.settings.hooks, declaredServers, dialect),
|
|
624
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),
|
|
625
816
|
malformedFrontmatter: malformedFrontmatterFor(loaded.files, cls),
|
|
626
817
|
warnings: loaded.warnings,
|
|
627
818
|
untested: (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir, layout: lay }).untested
|
|
@@ -811,6 +1002,13 @@ function formatScanReport(r) {
|
|
|
811
1002
|
out.push(...section("MCP config", r.mcpIssues.map((i) => ` ✗ ${i.message}`)));
|
|
812
1003
|
out.push(...section("MCP hook targets", r.mcpHookIssues.map((i) => ` ✗ ${i.message}`)));
|
|
813
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}`)));
|
|
814
1012
|
const facts = [];
|
|
815
1013
|
if (r.commands > 0)
|
|
816
1014
|
facts.push(`Commands: ${String(r.commands)}`);
|
|
@@ -865,7 +1063,15 @@ function formatScanReport(r) {
|
|
|
865
1063
|
r.frontmatterIssues.length +
|
|
866
1064
|
r.frontmatterValueIssues.length +
|
|
867
1065
|
r.mcpIssues.length +
|
|
868
|
-
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;
|
|
869
1075
|
out.push(broken === 0
|
|
870
1076
|
? "✓ no structural issues found"
|
|
871
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",
|