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.
Files changed (38) hide show
  1. package/README.md +113 -83
  2. package/dist/adapters/claude-code/dialect.js +15 -0
  3. package/dist/audit-report.d.ts +1 -1
  4. package/dist/audit-report.template.html +1 -1
  5. package/dist/audit-score.d.ts +19 -12
  6. package/dist/audit-score.js +65 -11
  7. package/dist/cli.js +249 -0
  8. package/dist/core/CLAUDE.md.spec.d.ts +3 -0
  9. package/dist/core/CLAUDE.md.spec.js +26 -0
  10. package/dist/core/delegation-trifecta.d.ts +64 -0
  11. package/dist/core/delegation-trifecta.js +124 -0
  12. package/dist/core/dialect.d.ts +18 -0
  13. package/dist/core/hook-block-ineffective.d.ts +62 -0
  14. package/dist/core/hook-block-ineffective.js +153 -0
  15. package/dist/core/hook-matcher.d.ts +66 -0
  16. package/dist/core/hook-matcher.js +182 -0
  17. package/dist/core/hook-normalize.d.ts +43 -0
  18. package/dist/core/hook-normalize.js +78 -0
  19. package/dist/core/lethal-trifecta.d.ts +100 -0
  20. package/dist/core/lethal-trifecta.js +197 -0
  21. package/dist/core/plugin-dir-layout.d.ts +30 -0
  22. package/dist/core/plugin-dir-layout.js +73 -0
  23. package/dist/core/rule-meta.d.ts +82 -0
  24. package/dist/core/rule-meta.js +266 -0
  25. package/dist/core/skill-missing-fence.d.ts +47 -0
  26. package/dist/core/skill-missing-fence.js +119 -0
  27. package/dist/core/skill-resources.d.ts +27 -0
  28. package/dist/core/skill-resources.js +167 -0
  29. package/dist/core/types.d.ts +71 -0
  30. package/dist/core/validate.d.ts +1 -0
  31. package/dist/core/validate.js +26 -4
  32. package/dist/leaderboard.d.ts +1 -0
  33. package/dist/leaderboard.js +42 -4
  34. package/dist/scan.d.ts +106 -0
  35. package/dist/scan.js +251 -45
  36. package/dist/setup-plan.d.ts +6 -3
  37. package/dist/setup-plan.js +12 -2
  38. 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
- function scanSkills(files, cls) {
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: /^\s*disable-model-invocation:\s*true\s*$/m.test(md),
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(hooks) {
382
+ function eventsByScript(regs) {
328
383
  const map = new Map();
329
- if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
330
- return map;
331
- for (const [event, arr] of Object.entries(hooks)) {
332
- if (!Array.isArray(arr))
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(settings, root, pluginRootToken) {
352
- const text = JSON.stringify(settings.hooks ?? {});
353
- const commands = [...text.matchAll(/"command":\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
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.replace(/\\(.)/g, "$1").trim();
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 unescaped = cmd.replace(/\\(.)/g, "$1");
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(unescaped)) {
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, unescaped);
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
- const { hooks, inline, manual } = scanHooks(loaded.settings, (0, node_path_1.resolve)(dir), lay.pluginRootToken);
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",…}]`) — Object.keys would read
586
- // array INDICES, a false positive. We don't interpret a format we don't own.
587
- const hooksObj = loaded.settings.hooks;
588
- const eventNames = hooksObj !== null &&
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 puritySummary = agents.reduce((acc, a) => {
604
- acc[a.purity]++;
605
- return acc;
606
- }, { pure: 0, bounded: 0, unrestricted: 0 });
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: scanSkills(loaded.files, cls),
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`);
@@ -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). `init` does not write these — they keep their own default
93
- * severities. Named for the group taxonomy (research/install-enforcement-dx.md).
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;
@@ -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). `init` does not write these — they keep their own default
116
- * severities. Named for the group taxonomy (research/install-enforcement-dx.md).
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": "10.0.0",
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",