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
@@ -3,23 +3,30 @@
3
3
  *
4
4
  * A single structural-health number (the leaderboard's `scoreReport`) ranks
5
5
  * plugins, but it hides WHERE a harness is weak. This buckets the SAME
6
- * deterministic findings into four categories — Truthfulness, Triggering,
7
- * Structure, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown beneath one
8
- * headline `overall` = `100 − Σ(all graded penalties)` (the SAME summed model as
9
- * the leaderboard's single health number, via the shared `computeIntegrityScore`
10
- * — so the two surfaces never disagree). Same detectors, no re-detection
11
- * (one-detector-no-drift); all deterministic, no execution. (Safety — "do your
12
- * hooks actually block?" — is NOT an `audit` ring:
13
- * it requires executing your hooks, which needs cross-platform confinement
14
- * that isn't shipped yet, so it lives in the `vigiles/testing` API via
15
- * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.)
6
+ * deterministic findings into five categories — Truthfulness, Triggering,
7
+ * Structure, Safety, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown
8
+ * beneath one headline `overall` = `100 − Σ(all graded penalties)` (the SAME
9
+ * summed model as the leaderboard's single health number, via the shared
10
+ * `computeIntegrityScore` — so the two surfaces never disagree). Same detectors,
11
+ * no re-detection (one-detector-no-drift); all deterministic, no execution.
12
+ *
13
+ * SAFETY is fed by the STATIC lethal-trifecta capability check
14
+ * (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
15
+ * capability legs is a prompt-injection exfil path detectable from the tool-SET
16
+ * alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
17
+ * (explicit all-three) finding is GRADED into the overall; a `"advisory"`
18
+ * (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
19
+ * "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
20
+ * running arbitrary hooks safely needs cross-platform confinement that isn't
21
+ * shipped yet, so the battery lives in the `vigiles/testing` API via
22
+ * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.
16
23
  *
17
24
  * A category that can't be assessed scores `null` (n/a) and is EXCLUDED from the
18
25
  * overall — never a false 0. Pure over the `ScanReport`, so it's fully testable.
19
26
  */
20
27
  import { type PluginScore } from "./leaderboard.js";
21
28
  import type { ScanReport } from "./scan.js";
22
- export type CategoryKey = "Truthfulness" | "Triggering" | "Structure" | "Tested";
29
+ export type CategoryKey = "Truthfulness" | "Triggering" | "Structure" | "Safety" | "Tested";
23
30
  export interface CategoryScore {
24
31
  readonly key: CategoryKey;
25
32
  /** 0–100, or `null` when the category isn't assessable (n/a — excluded from overall). */
@@ -52,7 +59,7 @@ export interface AuditScore {
52
59
  readonly empty: boolean;
53
60
  }
54
61
  /**
55
- * Bucket a scan report into the four deterministic Lighthouse categories as a
62
+ * Bucket a scan report into the five deterministic Lighthouse categories as a
56
63
  * DIAGNOSTIC breakdown, with the headline `overall` = `100 − Σ(all graded
57
64
  * penalties)` (the shared summed model — NOT the average of the rings — so it
58
65
  * equals the leaderboard's single health number). The advisory Tested ring and
@@ -7,16 +7,23 @@ exports.formatAuditScore = formatAuditScore;
7
7
  *
8
8
  * A single structural-health number (the leaderboard's `scoreReport`) ranks
9
9
  * plugins, but it hides WHERE a harness is weak. This buckets the SAME
10
- * deterministic findings into four categories — Truthfulness, Triggering,
11
- * Structure, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown beneath one
12
- * headline `overall` = `100 − Σ(all graded penalties)` (the SAME summed model as
13
- * the leaderboard's single health number, via the shared `computeIntegrityScore`
14
- * — so the two surfaces never disagree). Same detectors, no re-detection
15
- * (one-detector-no-drift); all deterministic, no execution. (Safety — "do your
16
- * hooks actually block?" — is NOT an `audit` ring:
17
- * it requires executing your hooks, which needs cross-platform confinement
18
- * that isn't shipped yet, so it lives in the `vigiles/testing` API via
19
- * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.)
10
+ * deterministic findings into five categories — Truthfulness, Triggering,
11
+ * Structure, Safety, Tested — each a 0–100 ring, as a DIAGNOSTIC breakdown
12
+ * beneath one headline `overall` = `100 − Σ(all graded penalties)` (the SAME
13
+ * summed model as the leaderboard's single health number, via the shared
14
+ * `computeIntegrityScore` — so the two surfaces never disagree). Same detectors,
15
+ * no re-detection (one-detector-no-drift); all deterministic, no execution.
16
+ *
17
+ * SAFETY is fed by the STATIC lethal-trifecta capability check
18
+ * (`lethalTrifectaIssues` → `report.trifectaFindings`): a unit holding all three
19
+ * capability legs is a prompt-injection exfil path detectable from the tool-SET
20
+ * alone — nothing executes, so it sidesteps the confinement blocker. A `"hard"`
21
+ * (explicit all-three) finding is GRADED into the overall; a `"advisory"`
22
+ * (inherits-all) finding is SHOWN in the ring but not graded. NB the EXECUTING
23
+ * "do your hooks actually block?" disaster-battery is STILL not an `audit` ring:
24
+ * running arbitrary hooks safely needs cross-platform confinement that isn't
25
+ * shipped yet, so the battery lives in the `vigiles/testing` API via
26
+ * `guardrail-check`/`assertBlocksDisasters`, where you opt in explicitly.
20
27
  *
21
28
  * A category that can't be assessed scores `null` (n/a) and is EXCLUDED from the
22
29
  * overall — never a false 0. Pure over the `ScanReport`, so it's fully testable.
@@ -138,6 +145,51 @@ function structure(r) {
138
145
  findings: [...findings, ...advisory],
139
146
  };
140
147
  }
148
+ /**
149
+ * SAFETY — fed by the STATIC lethal-trifecta check (`report.trifectaFindings`).
150
+ * A `"hard"` finding (an explicit contract naming all three capability legs) is a
151
+ * declared prompt-injection exfil path and is GRADED (`W_TRIFECTA` each, the same
152
+ * weight `reportDeductions` sums into the overall, so the ring and the headline
153
+ * agree). A `"advisory"` finding (inherits-all) is SHOWN in the ring's findings
154
+ * but NOT graded — aligned with the inherits-all-is-advisory stance.
155
+ *
156
+ * Scores `null` (n/a, excluded from the overall) when there's NO tool-bearing
157
+ * surface to assess at all — no subagents AND no model-invocable skills. A
158
+ * user-invoked skill carries no model-driven trifecta risk, so it doesn't count
159
+ * as an assessable surface. When there ARE assessable surfaces but no trifecta,
160
+ * the ring is a clean 100.
161
+ */
162
+ function safety(r) {
163
+ const modelInvocableSkills = r.skills.filter((s) => !s.userInvoked).length;
164
+ const assessable = r.agents.length + modelInvocableSkills;
165
+ if (assessable === 0) {
166
+ return {
167
+ key: "Safety",
168
+ score: null,
169
+ weight: 1,
170
+ findings: ["no tool-bearing surface to assess"],
171
+ };
172
+ }
173
+ const hard = r.trifectaFindings.filter((f) => f.finding.severity === "hard");
174
+ const { score, findings } = scoreFrom([
175
+ {
176
+ n: hard.length,
177
+ weight: leaderboard_js_1.W_TRIFECTA,
178
+ label: "unit(s) holding all three lethal-trifecta legs (prompt-injection exfil path)",
179
+ },
180
+ ]);
181
+ // inherits-all trifecta findings are ADVISORY: surfaced as a maximal-blast-radius
182
+ // note but never graded (mirrors the Structure inherits-all advisory).
183
+ const advisory = r.trifectaFindings
184
+ .filter((f) => f.finding.severity === "advisory")
185
+ .map((f) => `${f.name} inherits all tools — maximal trifecta blast radius (advisory)`);
186
+ return {
187
+ key: "Safety",
188
+ score,
189
+ weight: 1,
190
+ findings: [...findings, ...advisory],
191
+ };
192
+ }
141
193
  function tested(r) {
142
194
  const { score, findings } = scoreFrom([
143
195
  { n: r.untested, weight: W_UNTESTED, label: "untested surface(s)" },
@@ -157,7 +209,7 @@ function isEmptyAudit(r) {
157
209
  return (0, leaderboard_js_1.isEmptyMachine)(r) && !r.instructions;
158
210
  }
159
211
  /**
160
- * Bucket a scan report into the four deterministic Lighthouse categories as a
212
+ * Bucket a scan report into the five deterministic Lighthouse categories as a
161
213
  * DIAGNOSTIC breakdown, with the headline `overall` = `100 − Σ(all graded
162
214
  * penalties)` (the shared summed model — NOT the average of the rings — so it
163
215
  * equals the leaderboard's single health number). The advisory Tested ring and
@@ -169,6 +221,7 @@ function auditScore(report) {
169
221
  "Truthfulness",
170
222
  "Triggering",
171
223
  "Structure",
224
+ "Safety",
172
225
  "Tested",
173
226
  ];
174
227
  return {
@@ -187,6 +240,7 @@ function auditScore(report) {
187
240
  truthfulness(report),
188
241
  triggering(report),
189
242
  structure(report),
243
+ safety(report),
190
244
  tested(report),
191
245
  ];
192
246
  // The headline is the SUMMED model (the shared integrity score), NOT the average
package/dist/cli.js CHANGED
@@ -678,6 +678,13 @@ function lintExitCode(report) {
678
678
  report.frontmatterValidErrors > 0 ||
679
679
  report.mcpHookErrors > 0 ||
680
680
  report.preferCompiledHookErrors > 0 ||
681
+ report.lethalTrifectaErrors > 0 ||
682
+ report.skillResourceErrors > 0 ||
683
+ report.skillFenceErrors > 0 ||
684
+ report.pluginLayoutErrors > 0 ||
685
+ report.delegationTrifectaErrors > 0 ||
686
+ report.hookBlockErrors > 0 ||
687
+ report.hookMatcherErrors > 0 ||
681
688
  report.symbolRefErrors > 0 ||
682
689
  report.mcpRefErrors > 0)
683
690
  return 2;
@@ -1028,6 +1035,29 @@ async function runLint(restArgs, flags, config) {
1028
1035
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1029
1036
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1030
1037
  const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter);
1038
+ // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1039
+ // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1040
+ // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1041
+ const lethalTrifecta = checkLethalTrifecta(config, silent, adapter);
1042
+ // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1043
+ // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1044
+ const skillResources = checkSkillResourceResolves(config, silent, adapter);
1045
+ // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1046
+ // `---` fence loads as plain body (invisible — no name/description/trigger).
1047
+ const skillFence = checkSkillMissingFence(config, silent, adapter);
1048
+ // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1049
+ // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1050
+ const pluginLayout = checkPluginDirLayout(config, silent, adapter);
1051
+ // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1052
+ // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1053
+ const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter);
1054
+ // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1055
+ // doesn't (block decision on a non-blocking event, or the legacy `decision`
1056
+ // field on a permission-gated event). The #1 verified hook pain (#19009).
1057
+ const hookBlock = checkHookBlockIneffective(config, silent, adapter);
1058
+ // 7u. Hook-matcher — a hook `matcher` that never fires (tool-name typo, or a
1059
+ // malformed/undeclared MCP form).
1060
+ const hookMatcher = checkHookMatcher(config, silent, adapter);
1031
1061
  // 8. Validate vigiles builder calls inside markdown code blocks. Default
1032
1062
  // is to validate every ref; illustrative blocks opt out via
1033
1063
  // `<!-- vigiles:ignore -->` (single block) or
@@ -1095,6 +1125,20 @@ async function runLint(restArgs, flags, config) {
1095
1125
  mcpHookErrors: mcpHookTargets.errors,
1096
1126
  preferCompiledHookIssues: preferCompiledHooks.issues,
1097
1127
  preferCompiledHookErrors: preferCompiledHooks.errors,
1128
+ lethalTrifectaIssues: lethalTrifecta.issues,
1129
+ lethalTrifectaErrors: lethalTrifecta.errors,
1130
+ skillResourceIssues: skillResources.issues,
1131
+ skillResourceErrors: skillResources.errors,
1132
+ skillFenceIssues: skillFence.issues,
1133
+ skillFenceErrors: skillFence.errors,
1134
+ pluginLayoutIssues: pluginLayout.issues,
1135
+ pluginLayoutErrors: pluginLayout.errors,
1136
+ delegationTrifectaIssues: delegationTrifecta.issues,
1137
+ delegationTrifectaErrors: delegationTrifecta.errors,
1138
+ hookBlockIssues: hookBlock.issues,
1139
+ hookBlockErrors: hookBlock.errors,
1140
+ hookMatcherIssues: hookMatcher.issues,
1141
+ hookMatcherErrors: hookMatcher.errors,
1098
1142
  docRefErrors: docRefReport.errors.length,
1099
1143
  symbolRefErrors,
1100
1144
  mcpRefErrors,
@@ -2673,6 +2717,211 @@ function checkDescriptionOverlap(config, silent, adapter) {
2673
2717
  }
2674
2718
  return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2675
2719
  }
2720
+ /**
2721
+ * Apply the `lethal-trifecta` rule: a unit (subagent / model-invocable skill)
2722
+ * whose declared tools hold all three legs (read-private + ingest-untrusted +
2723
+ * exfiltrate) is a prompt-injection exfil path (Meta's Rule of Two). Reuses
2724
+ * `scanPlugin`'s `trifectaFindings` (a capability SET-intersection, one detector,
2725
+ * no drift). Warning by default; "error" gates CI. Surfaces across BOTH subagents
2726
+ * and skills, so it is NOT gated on the `subagents` capability — a skill-only
2727
+ * harness still has the surface.
2728
+ */
2729
+ function checkLethalTrifecta(config, silent, adapter) {
2730
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
2731
+ if (!sev)
2732
+ return { issues: 0, errors: 0 };
2733
+ let found;
2734
+ try {
2735
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).trifectaFindings;
2736
+ }
2737
+ catch {
2738
+ return { issues: 0, errors: 0 };
2739
+ }
2740
+ if (found.length > 0 && !silent) {
2741
+ console.log("\nLethal-trifecta check:\n");
2742
+ for (const t of found) {
2743
+ const msg = `${t.kind} ${t.name}: ${t.finding.message}`;
2744
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${t.path}: ${msg}`);
2745
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, t.path);
2746
+ }
2747
+ }
2748
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2749
+ }
2750
+ /**
2751
+ * Apply the `skill-resource-resolves` rule: a SKILL.md body referencing a bundled
2752
+ * file (`scripts/`/`references/`/`assets/` or a relative markdown link with an
2753
+ * extension) that doesn't exist on disk — the agent reads the instruction and gets
2754
+ * nothing. Reuses `scanPlugin`'s `skillResourceIssues` (high-precision / FP-safe,
2755
+ * one detector, no drift). Warning by default; "error" gates CI.
2756
+ */
2757
+ function checkSkillResourceResolves(config, silent, adapter) {
2758
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
2759
+ if (!sev)
2760
+ return { issues: 0, errors: 0 };
2761
+ let found;
2762
+ try {
2763
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillResourceIssues;
2764
+ }
2765
+ catch {
2766
+ return { issues: 0, errors: 0 };
2767
+ }
2768
+ if (found.length > 0 && !silent) {
2769
+ console.log("\nSkill-resource check:\n");
2770
+ for (const s of found) {
2771
+ const msg = `${s.name}: bundled resource "${s.finding.ref}" (line ${String(s.finding.line)}) is referenced but missing — the agent reads the instruction and gets nothing.`;
2772
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
2773
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
2774
+ }
2775
+ }
2776
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2777
+ }
2778
+ /**
2779
+ * Apply the `skill-missing-fence` rule: a SKILL.md that opens with
2780
+ * frontmatter-looking keys (`name:`/`description:`) but no `---` fence loads as
2781
+ * pure body — no name, no description, no trigger (the skill is invisible).
2782
+ * Reuses `scanPlugin`'s `skillFenceIssues` (one detector, no drift). Warning by
2783
+ * default; "error" gates CI.
2784
+ */
2785
+ function checkSkillMissingFence(config, silent, adapter) {
2786
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
2787
+ if (!sev)
2788
+ return { issues: 0, errors: 0 };
2789
+ let found;
2790
+ try {
2791
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillFenceIssues;
2792
+ }
2793
+ catch {
2794
+ return { issues: 0, errors: 0 };
2795
+ }
2796
+ if (found.length > 0 && !silent) {
2797
+ console.log("\nSkill-missing-fence check:\n");
2798
+ for (const s of found) {
2799
+ const msg = `${s.name}: ${s.finding.message}`;
2800
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${s.path}: ${msg}`);
2801
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, s.path);
2802
+ }
2803
+ }
2804
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2805
+ }
2806
+ /**
2807
+ * Apply the `plugin-dir-layout` rule: functional surface dirs (skills/agents/
2808
+ * commands) nested inside the `.claude-plugin/` manifest dir where the harness
2809
+ * can't see them (the #1 plugin-author mistake). Reuses `scanPlugin`'s
2810
+ * `pluginLayoutIssues` (one detector, no drift). Warning by default; "error"
2811
+ * gates CI.
2812
+ */
2813
+ function checkPluginDirLayout(config, silent, adapter) {
2814
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
2815
+ if (!sev)
2816
+ return { issues: 0, errors: 0 };
2817
+ let found;
2818
+ try {
2819
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).pluginLayoutIssues;
2820
+ }
2821
+ catch {
2822
+ return { issues: 0, errors: 0 };
2823
+ }
2824
+ if (found.length > 0 && !silent) {
2825
+ console.log("\nPlugin-dir-layout check:\n");
2826
+ for (const p of found) {
2827
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${p.message}`);
2828
+ ghAnnotate(sev === "error" ? "error" : "warning", p.message);
2829
+ }
2830
+ }
2831
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2832
+ }
2833
+ /**
2834
+ * Apply the `delegation-trifecta` rule: a lethal trifecta that EMERGES across a
2835
+ * delegation edge — a subagent whose effective (own ∪ delegated-to) capability
2836
+ * holds all three legs though no single unit does. Reuses `scanPlugin`'s
2837
+ * `delegationTrifecta` (one detector, no drift). Warning by default; "error"
2838
+ * gates CI. Surfaces across the subagent graph, so it is NOT gated on a
2839
+ * capability the way a surface-specific rule is.
2840
+ */
2841
+ function checkDelegationTrifecta(config, silent, adapter) {
2842
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
2843
+ if (!sev)
2844
+ return { issues: 0, errors: 0 };
2845
+ let found;
2846
+ try {
2847
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).delegationTrifecta;
2848
+ }
2849
+ catch {
2850
+ return { issues: 0, errors: 0 };
2851
+ }
2852
+ if (found.length > 0 && !silent) {
2853
+ console.log("\nDelegation-trifecta check:\n");
2854
+ for (const d of found) {
2855
+ const msg = `${d.finding.name}: ${d.finding.message}`;
2856
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${d.path}: ${msg}`);
2857
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, d.path);
2858
+ }
2859
+ }
2860
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2861
+ }
2862
+ /**
2863
+ * Apply the `hook-block-ineffective` rule: a hook that LOOKS like it blocks but
2864
+ * silently doesn't — a block decision (`exit 2` / `decision` / `permissionDecision`)
2865
+ * on a non-blocking event, or the legacy top-level `decision` field on a
2866
+ * permission-gated event (#19009, the #1 verified hook pain). Reuses `scanPlugin`'s
2867
+ * `hookBlockFindings` (one detector, no drift). Warning by default; "error" gates CI.
2868
+ */
2869
+ function checkHookBlockIneffective(config, silent, adapter) {
2870
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-block-ineffective"]);
2871
+ if (!sev)
2872
+ return { issues: 0, errors: 0 };
2873
+ if (!adapter.capabilities.shellHooks) {
2874
+ reportNotApplicable("Hook-block check", "shell hooks", adapter, silent);
2875
+ return { issues: 0, errors: 0 };
2876
+ }
2877
+ let found;
2878
+ try {
2879
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookBlockFindings;
2880
+ }
2881
+ catch {
2882
+ return { issues: 0, errors: 0 };
2883
+ }
2884
+ if (found.length > 0 && !silent) {
2885
+ console.log("\nHook-block check:\n");
2886
+ for (const h of found) {
2887
+ const where = h.scriptPath ?? "(inline)";
2888
+ const msg = `[${h.event}] ${where}: ${h.message}`;
2889
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${msg}`);
2890
+ ghAnnotate(sev === "error" ? "error" : "warning", msg, h.scriptPath ?? undefined);
2891
+ }
2892
+ }
2893
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2894
+ }
2895
+ /**
2896
+ * Apply the `hook-matcher` rule: a hook `matcher` string that silently never
2897
+ * fires — a tool-name typo (`bash`→`Bash`) or a malformed/undeclared MCP form.
2898
+ * Reuses `scanPlugin`'s `hookMatcherFindings` (one detector, no drift). Warning
2899
+ * by default; "error" gates CI.
2900
+ */
2901
+ function checkHookMatcher(config, silent, adapter) {
2902
+ const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-matcher"]);
2903
+ if (!sev)
2904
+ return { issues: 0, errors: 0 };
2905
+ if (!adapter.capabilities.shellHooks) {
2906
+ reportNotApplicable("Hook-matcher check", "shell hooks", adapter, silent);
2907
+ return { issues: 0, errors: 0 };
2908
+ }
2909
+ let found;
2910
+ try {
2911
+ found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookMatcherFindings;
2912
+ }
2913
+ catch {
2914
+ return { issues: 0, errors: 0 };
2915
+ }
2916
+ if (found.length > 0 && !silent) {
2917
+ console.log("\nHook-matcher check:\n");
2918
+ for (const m of found) {
2919
+ console.log(` ${sev === "error" ? "✗" : "⚠"} ${m.message}`);
2920
+ ghAnnotate(sev === "error" ? "error" : "warning", m.message);
2921
+ }
2922
+ }
2923
+ return { issues: found.length, errors: sev === "error" ? found.length : 0 };
2924
+ }
2676
2925
  /**
2677
2926
  * Apply the `mcp-hook-target-resolves` rule: a `type: "mcp_tool"` hook action
2678
2927
  * that's incomplete (no `server`/`tool`) or targets a server the plugin doesn't
@@ -0,0 +1,3 @@
1
+ declare const _default: import("./spec.js").ClaudeSpec;
2
+ export default _default;
3
+ //# sourceMappingURL=CLAUDE.md.spec.d.ts.map
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Directory-scoped guidance for working in `src/core/` (the harness-agnostic
5
+ * detectors + domain).
6
+ *
7
+ * The full project rule set is the ROOT `CLAUDE.md` (compiled from
8
+ * `CLAUDE.md.spec.ts`). This nested spec adds only the discipline that belongs
9
+ * next to the detectors themselves — Claude Code loads it as directory memory
10
+ * whenever you work in `src/core/`. Source of truth; `src/core/CLAUDE.md` is a
11
+ * compiled build artifact (`vigiles compile`).
12
+ */
13
+ const spec_js_1 = require("./spec.js");
14
+ exports.default = (0, spec_js_1.claude)({
15
+ sections: {
16
+ scope: `Working in \`src/core/\`? This is the harness-AGNOSTIC domain (spec, compile, linters, the lint/audit detectors). The root \`CLAUDE.md\` holds the full positioning + rule set — read it first. Two invariants live closest to this code: the core must not import an adapter (\`core ⊄ adapter\`, eslint-enforced) and must not hard-code a Claude Code literal (read it from the injected layout/dialect). This file adds the rule for ADDING or CHANGING a detector.`,
17
+ },
18
+ keyFiles: {
19
+ "src/core/rule-meta.ts": "The RuleMeta registry — every rule's decidability bucket + severity + detector, the single source the detector-meta rule enforces.",
20
+ "src/core/types.ts": "RulesConfig — the rule-name keys the registry is keyed on.",
21
+ },
22
+ rules: {
23
+ "detector-meta": (0, spec_js_1.guidance)("A deterministic DETECTOR here is one half of a RULE — and a rule is not done until it is DECLARED. Three things move together (sibling of one-detector-no-drift + rules-docs-in-sync): (1) the pure detector function (shared by `lint` AND `audit`, never reimplemented per surface; read the layout/dialect, never a CC literal); (2) its entry in `src/core/rule-meta.ts` — the `Record<RuleName, RuleMeta>` won't typecheck without it — declaring its DECIDABILITY BUCKET (structural-closed = a type could prevent it / external-decidable = needs the world, error-capable / heuristic-behavioral = warn-or-measure-only), surface, defaultSeverity, the detector name, and any upstreamPrevention; (3) its `docs/rules/<name>.md` (the coverage test binds the registry to the docs by an EXACT set match, so a missing meta or doc fails CI). The bucket is the CEILING, not a preference — a heuristic proxy may NEVER default to `error` (it cries wolf); a structural/external fact MAY, once proven FP-safe. Before writing a new detector, CLASSIFY the defect into a bucket — that decides whether it can ever gate. The full model + the prose behind the buckets is the root `lint-rule-calibration` rule and `research/enforcement-model.md`."),
24
+ },
25
+ });
26
+ //# sourceMappingURL=CLAUDE.md.spec.js.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * DELEGATION-TRIFECTA — the lethal trifecta SPLIT across a delegation edge.
3
+ *
4
+ * The per-unit {@link lethalTrifectaIssues} (`./lethal-trifecta.ts`) catches a
5
+ * single subagent / skill that holds all THREE capability legs at once. But the
6
+ * trifecta can EMERGE across a delegation (or inheritance) edge: a parent that
7
+ * can only read private data (leg A) delegates to a child that can ingest
8
+ * untrusted content AND exfiltrate (legs B+C). NEITHER unit trips the per-unit
9
+ * check, yet the CHAIN — the parent plus everything it can reach — forms the full
10
+ * trifecta. A prompt injection in the child's untrusted input can pivot back
11
+ * through the delegation and leak the parent's private data.
12
+ *
13
+ * This is CAPABILITY-DIFF ACROSS THE DELEGATION TREE: the EFFECTIVE (combined)
14
+ * capability of a unit is the union of its own tools plus the tools of every unit
15
+ * reachable through `delegatesTo`. We classify that effective set and flag a unit
16
+ * whose effective set is a full trifecta while its OWN set is not.
17
+ *
18
+ * NO DOUBLE-REPORT (one-detector-no-drift / don't-cry-wolf): a unit whose OWN
19
+ * tools already form a full trifecta is SKIPPED here — {@link lethalTrifectaIssues}
20
+ * owns it. This detector reports ONLY the EMERGENT case the per-unit check can't
21
+ * see.
22
+ *
23
+ * HIGH-PRECISION (FP-safe): if the effective set contains a wildcard (inherits-all)
24
+ * the unit reaches everything and would always "trifecta" — that maximal-blast-
25
+ * radius case is the per-unit ADVISORY detector's job, so we SKIP it. We flag ONLY
26
+ * concrete, explicit tool unions where every leg is supplied by a named tool.
27
+ *
28
+ * Pure, no IO. The delegation graph (nodes + directed `delegatesTo` edges) is the
29
+ * INPUT — this module does not decide where edges come from; a caller supplies them
30
+ * from the parsed harness. The dialect is injected (core ⊄ adapter), reused for the
31
+ * underlying leg classification.
32
+ */
33
+ import type { HarnessDialect } from "./dialect.js";
34
+ import { type TrifectaLegs } from "./lethal-trifecta.js";
35
+ /** One unit (subagent/skill) in the delegation graph. */
36
+ export interface CapabilityNode {
37
+ readonly name: string;
38
+ readonly kind: "skill" | "agent";
39
+ /** This unit's OWN declared tools. [] = none declared. ["*"] = inherits-all (wildcard). */
40
+ readonly tools: readonly string[];
41
+ /** Names of units this one can delegate to / inherits capabilities from (directed edges). */
42
+ readonly delegatesTo: readonly string[];
43
+ }
44
+ /** A trifecta that EMERGES across delegation — present in a unit's effective set but NOT its own. */
45
+ export interface DelegationTrifectaFinding {
46
+ readonly name: string;
47
+ readonly kind: "skill" | "agent";
48
+ /** The delegated-to units (by name) that supplied at least one leg the unit lacks on its own. */
49
+ readonly via: readonly string[];
50
+ /** The tools that supplied each leg in the EFFECTIVE (combined) set. */
51
+ readonly legs: TrifectaLegs;
52
+ /** Ready-to-show, actionable message. */
53
+ readonly message: string;
54
+ }
55
+ /**
56
+ * Find units whose EFFECTIVE (own + delegated) capability set forms a full lethal
57
+ * trifecta that their OWN set does not — an emergent, cross-delegation exfil path.
58
+ *
59
+ * Returns findings in stable order (by node name). See the module header for the
60
+ * skip rules (own-set already a trifecta → owned by the per-unit detector; an
61
+ * effective wildcard → owned by the per-unit advisory).
62
+ */
63
+ export declare function delegationTrifectaIssues(nodes: readonly CapabilityNode[], dialect: HarnessDialect): DelegationTrifectaFinding[];
64
+ //# sourceMappingURL=delegation-trifecta.d.ts.map
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.delegationTrifectaIssues = delegationTrifectaIssues;
4
+ const lethal_trifecta_js_1 = require("./lethal-trifecta.js");
5
+ // ---------------------------------------------------------------------------
6
+ // Internal helpers
7
+ // ---------------------------------------------------------------------------
8
+ /** A full trifecta = all three legs non-empty. */
9
+ function isFullTrifecta(legs) {
10
+ return (legs.private.length > 0 &&
11
+ legs.untrusted.length > 0 &&
12
+ legs.exfil.length > 0);
13
+ }
14
+ /** Strips a `Tool(restriction)` suffix and returns the base tool name. */
15
+ function baseTool(raw) {
16
+ return raw.split("(")[0].trim();
17
+ }
18
+ /** True for the wildcard sentinels that mean "inherits-all". */
19
+ function isWildcard(tool) {
20
+ return tool === "" || tool === "*";
21
+ }
22
+ /**
23
+ * The set of node names reachable from `start` over `delegatesTo`, INCLUDING
24
+ * `start` itself. Cycle-safe (a `visited` set). An edge naming a node not in the
25
+ * map is skipped — its tools can't be resolved.
26
+ */
27
+ function effectiveReach(start, byName) {
28
+ const visited = new Set();
29
+ const stack = [start];
30
+ while (stack.length > 0) {
31
+ const name = stack.pop();
32
+ if (name === undefined || visited.has(name))
33
+ continue;
34
+ visited.add(name);
35
+ const node = byName.get(name);
36
+ if (node === undefined)
37
+ continue;
38
+ for (const next of node.delegatesTo) {
39
+ if (!visited.has(next))
40
+ stack.push(next);
41
+ }
42
+ }
43
+ return visited;
44
+ }
45
+ // ---------------------------------------------------------------------------
46
+ // Public API
47
+ // ---------------------------------------------------------------------------
48
+ /**
49
+ * Find units whose EFFECTIVE (own + delegated) capability set forms a full lethal
50
+ * trifecta that their OWN set does not — an emergent, cross-delegation exfil path.
51
+ *
52
+ * Returns findings in stable order (by node name). See the module header for the
53
+ * skip rules (own-set already a trifecta → owned by the per-unit detector; an
54
+ * effective wildcard → owned by the per-unit advisory).
55
+ */
56
+ function delegationTrifectaIssues(nodes, dialect) {
57
+ const byName = new Map();
58
+ for (const node of nodes)
59
+ byName.set(node.name, node);
60
+ const findings = [];
61
+ for (const node of nodes) {
62
+ // (b) If the unit's OWN tools already form a full trifecta, the per-unit
63
+ // detector owns it — never double-report.
64
+ const ownLegs = (0, lethal_trifecta_js_1.classifyTrifectaLegs)(node.tools, dialect);
65
+ if (isFullTrifecta(ownLegs))
66
+ continue;
67
+ // (c) Effective tools = the de-duplicated union across the reachable set.
68
+ const reach = effectiveReach(node.name, byName);
69
+ const effSet = new Set();
70
+ for (const name of reach) {
71
+ const reached = byName.get(name);
72
+ if (reached === undefined)
73
+ continue;
74
+ for (const tool of reached.tools)
75
+ effSet.add(tool);
76
+ }
77
+ const effectiveTools = [...effSet];
78
+ // (d) FP-safe wildcard guard: an inherits-all unit in the reachable set
79
+ // would always "trifecta" — that's the per-unit advisory's job.
80
+ if (effectiveTools.some((t) => isWildcard(baseTool(t))))
81
+ continue;
82
+ // (e) Classify the effective set.
83
+ const effLegs = (0, lethal_trifecta_js_1.classifyTrifectaLegs)(effectiveTools, dialect);
84
+ // (f) Emit ONLY when the effective set is a full trifecta (own set wasn't).
85
+ if (!isFullTrifecta(effLegs))
86
+ continue;
87
+ // The tools that supplied any leg in the effective set.
88
+ const legTools = new Set([
89
+ ...effLegs.private,
90
+ ...effLegs.untrusted,
91
+ ...effLegs.exfil,
92
+ ]);
93
+ // `via` = reachable units (excluding this node) that contribute a leg tool.
94
+ const via = [];
95
+ for (const name of reach) {
96
+ if (name === node.name)
97
+ continue;
98
+ const reached = byName.get(name);
99
+ if (reached === undefined)
100
+ continue;
101
+ const contributes = reached.tools.some((t) => legTools.has(baseTool(t)));
102
+ if (contributes && !via.includes(name))
103
+ via.push(name);
104
+ }
105
+ via.sort();
106
+ const message = `Subagent "${node.name}" is not a data-leak risk on its own, but combined ` +
107
+ `with what it delegates to (${via.join(", ")}), the chain can read private ` +
108
+ `data (${effLegs.private.join(", ")}), ingest untrusted content ` +
109
+ `(${effLegs.untrusted.join(", ")}), AND exfiltrate ` +
110
+ `(${effLegs.exfil.join(", ")}) — a prompt injection in the untrusted input ` +
111
+ `could pivot through the delegation to leak data. Break the delegation or ` +
112
+ `drop one leg.`;
113
+ findings.push({
114
+ name: node.name,
115
+ kind: node.kind,
116
+ via,
117
+ legs: effLegs,
118
+ message,
119
+ });
120
+ }
121
+ findings.sort((a, b) => a.name.localeCompare(b.name));
122
+ return findings;
123
+ }
124
+ //# sourceMappingURL=delegation-trifecta.js.map