@blamejs/exceptd-skills 0.19.32 → 0.19.34

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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,38 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * lib/lint-skills.js — exceptd skill pre-ship linter.
4
- *
5
- * Enforces AGENTS.md rules that are otherwise informal:
6
- * Rule #10 — No placeholder data. Skill bodies and frontmatter must not
7
- * contain TODO / TBD / coming soon / placeholder / fixme / XXX
8
- * / to be determined.
9
- * Rule #11 — No-MVP ban. Every skill ships with complete frontmatter,
10
- * all 7 required body sections, all data deps existing, and
11
- * all referenced TTPs / framework controls resolving.
12
- *
13
- * For every skill registered in manifest.json this linter checks:
14
- * - skill.md exists at the manifest path
15
- * - frontmatter contains every required field per AGENTS.md spec
16
- * - frontmatter values conform to lib/schemas/skill-frontmatter.schema.json
17
- * (the subset relevant for this codebase — no external validator dep)
18
- * - body contains all 7 required H2/H3 sections (case-insensitive):
19
- * Threat Context, Framework Lag Declaration, TTP Mapping,
20
- * Exploit Availability Matrix, Analysis Procedure, Output Format,
21
- * Compliance Theater Check
22
- * - body and frontmatter free of placeholder language
23
- * - every data_deps filename resolves to data/<filename>
24
- * - every atlas_refs ID exists as a top-level key in data/atlas-ttps.json
25
- * - every framework_gaps ID exists as a top-level key in
26
- * data/framework-control-gaps.json
27
- *
28
- * Usage:
29
- * node lib/lint-skills.js lint every skill
30
- * node lib/lint-skills.js --skill foo lint only the named skill
31
- * node lib/lint-skills.js --quiet only print failures and final summary
32
- *
33
- * Exit code: 0 if every linted skill passes, 1 otherwise.
34
- *
35
- * No external dependencies. Node 24 stdlib only.
3
+ * Pre-ship linter for the skills registered in manifest.json. Exit 0 when every
4
+ * linted skill passes, 1 otherwise.
36
5
  */
37
6
 
38
7
  'use strict';
@@ -69,12 +38,9 @@ const REQUIRED_FRONTMATTER_FIELDS = [
69
38
  'last_threat_review',
70
39
  ];
71
40
 
72
- // v0.13.2: `discovery_mode` documents how the skill is reached by operators.
73
- // Default (omitted) means the skill is referenced by at least one playbook's
74
- // direct.skill_chain. `standalone` means the skill is reached via
75
- // `exceptd brief <name>` or `exceptd ask` routing and is NOT chained — this
76
- // closes the v0.12 audit gap that 16 skills had no playbook chain pointing
77
- // at them. Operator intent now explicit; not a sign of orphan-skill drift.
41
+ // `discovery_mode` records how operators reach the skill: omitted means a
42
+ // playbook's direct.skill_chain references it; `standalone` means it is reached
43
+ // through `exceptd brief <name>` or `ask` routing, so no chain is deliberate.
78
44
  const OPTIONAL_FRONTMATTER_FIELDS = ['forward_watch', 'rfc_refs', 'cwe_refs', 'd3fend_refs', 'dlp_refs', 'discovery_mode'];
79
45
 
80
46
  const ALL_KNOWN_FIELDS = new Set([
@@ -92,15 +58,12 @@ const REQUIRED_SECTIONS = [
92
58
  'Compliance Theater Check',
93
59
  ];
94
60
 
95
- // L3 — Defensive Countermeasure Mapping became a required section for skills
96
- // reviewed on or after this cutoff (documented in AGENTS.md). Pre-cutoff
97
- // skills remain exempt to preserve patch-class compatibility.
61
+ // Required only for skills whose last_threat_review is on or after the cutoff.
98
62
  const COUNTERMEASURE_SECTION = 'Defensive Countermeasure Mapping';
99
63
  const COUNTERMEASURE_CUTOFF = '2026-05-11';
100
64
 
101
- // L1 — Minimum number of words of body text between a section heading and the
102
- // next heading (or EOF) for the section to count as populated. Header-only
103
- // sections surface as warnings; promoted to failures under --strict.
65
+ // Words of body text between a section heading and the next (or EOF) for the
66
+ // section to count as populated. Below it is a warning, an error under --strict.
104
67
  const MIN_SECTION_BODY_WORDS = 20;
105
68
 
106
69
  const PLACEHOLDER_PATTERNS = [
@@ -119,12 +82,9 @@ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
119
82
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
120
83
 
121
84
  /*
122
- * Strict ISO calendar-date check. ISO_DATE_RE only proves the SHAPE
123
- * (YYYY-MM-DD); Date.parse() silently rolls non-calendar dates over —
124
- * 2026-02-30 becomes 2026-03-02, 2026-04-31 becomes 2026-05-01 — so a
125
- * malformed review date would pass the staleness gate against a date that
126
- * never existed. Round-trip through Date.UTC and require every component to
127
- * survive unchanged, which rejects the rollovers while accepting real dates.
85
+ * ISO_DATE_RE proves only the shape, and Date.parse silently rolls a
86
+ * non-calendar date over (2026-02-30 → 2026-03-02), measuring the staleness
87
+ * gate against a day that never existed.
128
88
  */
129
89
  function isStrictIsoCalendarDate(s) {
130
90
  if (typeof s !== 'string' || !ISO_DATE_RE.test(s)) return false;
@@ -151,9 +111,7 @@ function parseArgs(argv) {
151
111
  } else if (a === '--quiet' || a === '-q') {
152
112
  opts.quiet = true;
153
113
  } else if (a === '--strict') {
154
- // Promote warnings (header-only sections, unresolved draft refs,
155
- // playbook air-gap gaps) to release-blocking failures. Used by the
156
- // predeploy gate so a warned regression cannot scroll past.
114
+ // The predeploy gate runs --strict, so a warned regression cannot scroll past.
157
115
  opts.strict = true;
158
116
  } else if (a === '--help' || a === '-h') {
159
117
  printHelp();
@@ -183,30 +141,16 @@ function readJson(p) {
183
141
  }
184
142
 
185
143
  /*
186
- * Minimal YAML frontmatter parser. Supports the subset actually used in this
187
- * repo:
188
- * key: "quoted string"
189
- * key: bare-string
190
- * key: [] (empty list)
191
- * key:
192
- * - item one
193
- * - "item two"
194
- * Anything outside this shape produces a parse error so we don't silently
195
- * accept malformed frontmatter.
144
+ * Minimal YAML frontmatter parser: quoted or bare scalars, `[]`, and indented
145
+ * `- ` item lists. Anything outside that shape throws.
196
146
  */
197
147
  function parseFrontmatter(text) {
198
- // Strip a trailing CR per line: split(/\r?\n/) consumes interior CRLFs, but a
199
- // dangling `\r` survives on the final frontmatter line (the close marker
200
- // consumed the `\n`, not the `\r`). `.` does not match `\r`, so that line's
201
- // value would fail the per-line regex with a misleading "Could not parse
202
- // frontmatter line N" on an otherwise valid CRLF skill.md.
148
+ // A dangling `\r` survives on the final frontmatter line (the close marker took
149
+ // the `\n`), and `.` does not match `\r`, so the per-line regex would fail.
203
150
  const lines = text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
204
151
  const result = {};
205
- // Track every top-level key we've already assigned. YAML's last-wins
206
- // semantics would let a tampered skill set name twice
207
- // ("name: real-skill\nname: evil-skill") and silently take the second
208
- // value — a skill-identity spoofing primitive. Refuse duplicates
209
- // outright; an honest skill never has them.
152
+ // YAML's last-wins semantics would let "name: real\nname: evil" take the
153
+ // second value — a skill-identity spoof — so a duplicate key is refused.
210
154
  const seenKeys = new Set();
211
155
  let i = 0;
212
156
  while (i < lines.length) {
@@ -261,11 +205,8 @@ function unquote(s) {
261
205
  if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
262
206
  return s.slice(1, -1);
263
207
  }
264
- // Quoted scalar followed by an inline comment, e.g.
265
- // "standalone" # why this skill is standalone
266
- // The value is the quoted content; everything after the closing quote is a
267
- // YAML comment. Only applied when the trailing segment is whitespace + `#`
268
- // so a mid-string `#` inside an unquoted value is never mistaken for one.
208
+ // A quoted scalar trailed by an inline comment (`"standalone" # why`). Only
209
+ // when the tail is whitespace + `#`, so a `#` inside a value is not a comment.
269
210
  if (first === '"' || first === "'") {
270
211
  const close = s.indexOf(first, 1);
271
212
  if (close > 0) {
@@ -294,18 +235,12 @@ function extractFrontmatterBlock(content) {
294
235
  return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
295
236
  }
296
237
 
297
- // Array-item pattern constraints already enforced above by dedicated regexes
298
- // (ATLAS_ID_RE / ATTACK_ID_RE / JSON_FILENAME_RE) with their own error wording.
299
- // The schema-driven pass below skips these so a field is never reported twice.
238
+ // Fields with a dedicated regex and error wording above; the schema-driven pass
239
+ // skips them so a field is never reported twice.
300
240
  const SCHEMA_PATTERN_HANDLED_ELSEWHERE = new Set(['atlas_refs', 'attack_refs', 'data_deps']);
301
241
 
302
242
  /* Enforce the enum and array-item pattern constraints declared in
303
- * lib/schemas/skill-frontmatter.schema.json, so the shipped schema is the
304
- * source of truth rather than a decorative artifact. Covers the schema's
305
- * `enum` constraints (e.g. discovery_mode) and the `items.pattern` format
306
- * checks on ref arrays (cwe_refs / d3fend_refs / dlp_refs / rfc_refs) that the
307
- * hand-coded checks did not apply. Returns errors in the existing
308
- * `frontmatter.<field> ...` voice. */
243
+ * lib/schemas/skill-frontmatter.schema.json. */
309
244
  function schemaConstraintErrors(fm, schema) {
310
245
  const errors = [];
311
246
  const props = (schema && schema.properties) || {};
@@ -313,11 +248,8 @@ function schemaConstraintErrors(fm, schema) {
313
248
  if (!(field in fm)) continue;
314
249
  const value = fm[field];
315
250
 
316
- // An enum constraint must reject a wrong-typed value, not skip it. The
317
- // earlier `typeof value === 'string'` guard silently passed any non-string
318
- // (e.g. `discovery_mode: [standalone]` parsed as an array, or a number),
319
- // letting a malformed field bypass validation entirely. Flag the type
320
- // mismatch first; only run the membership check on an actual string.
251
+ // Type first, membership only on a string: guarding on `typeof value ===
252
+ // 'string'` would let `discovery_mode: [standalone]` through unvalidated.
321
253
  if (Array.isArray(spec.enum)) {
322
254
  if (typeof value !== 'string') {
323
255
  errors.push(
@@ -350,13 +282,10 @@ function schemaConstraintErrors(fm, schema) {
350
282
  return errors;
351
283
  }
352
284
 
353
- /* Validate frontmatter object against the codified schema rules. */
285
+ /* Returns { errors, warnings } — the review-staleness soft cap warns without
286
+ * failing, so callers must read both arrays. */
354
287
  function validateFrontmatter(fm, skillName) {
355
288
  const errors = [];
356
- // v0.13.0: validateFrontmatter now ALSO surfaces warnings (e.g. the
357
- // last_threat_review 180-day soft cap). Return signature changes from
358
- // `string[]` to `{ errors: string[], warnings: string[] }` — callers
359
- // updated accordingly.
360
289
  const warnings = [];
361
290
 
362
291
  for (const key of Object.keys(fm)) {
@@ -467,10 +396,6 @@ function validateFrontmatter(fm, skillName) {
467
396
  `frontmatter.last_threat_review "${fm.last_threat_review}" is not a valid ISO date (YYYY-MM-DD). A structurally ISO but non-calendar value (e.g. 2026-13-99 or a rollover like 2026-02-30) is rejected so a malformed date cannot slip past the staleness gate.`,
468
397
  );
469
398
  } else {
470
- // v0.13.0: Hard Rule #8 forcing function — refuse skills whose
471
- // last_threat_review is older than the staleness threshold.
472
- // 180-day soft cap (warn), 365-day hard cap (fail). Operators on
473
- // older releases who don't refresh fall off the supported window.
474
399
  const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
475
400
  if (days > 365) {
476
401
  errors.push(
@@ -484,30 +409,19 @@ function validateFrontmatter(fm, skillName) {
484
409
  }
485
410
  }
486
411
 
487
- // Drive enum + ref-pattern enforcement from the published schema so the
488
- // shipped artifact and this gate cannot diverge.
489
412
  errors.push(...schemaConstraintErrors(fm, FRONTMATTER_SCHEMA));
490
413
 
491
414
  return { errors, warnings };
492
415
  }
493
416
 
494
- /* L1 — Heading-anchored section detection.
495
- *
496
- * Returns { missing, headerOnly }:
497
- * - missing[] — sections with no `^## <Section Name>` heading anywhere
498
- * in the body (case-insensitive). Hard failure.
499
- * - headerOnly[] — sections whose heading exists but whose body between
500
- * that heading and the next heading is shorter than
501
- * MIN_SECTION_BODY_WORDS words. A warning by default;
502
- * promoted to an error under --strict. */
417
+ /* Returns { missing, headerOnly }: sections with no heading in the body, and
418
+ * sections whose heading exists but whose text runs shorter than
419
+ * MIN_SECTION_BODY_WORDS. */
503
420
  function findMissingSections(body, requiredSections) {
504
421
  const sections = requiredSections || REQUIRED_SECTIONS;
505
422
  const lines = body.split(/\r?\n/);
506
- // Index heading lines so we know where each section ends. Skip lines inside
507
- // fenced code blocks: a heading inside a ```markdown example block is
508
- // documentation, not a real section, and must not satisfy a required-section
509
- // check (otherwise a skill whose required sections appear only inside a fence
510
- // passes the gate vacuously).
423
+ // Headings inside a fenced block are documentation: counting them lets a skill
424
+ // whose sections appear only inside a fence pass the gate vacuously.
511
425
  const headings = [];
512
426
  let inFence = false;
513
427
  for (let i = 0; i < lines.length; i++) {
@@ -518,17 +432,13 @@ function findMissingSections(body, requiredSections) {
518
432
  headings.push({ line: i, depth: m[1].length, title: m[2].trim() });
519
433
  }
520
434
  }
521
- // Heading match is case-insensitive and tolerates trailing context
522
- // qualifiers (e.g. "## Threat Context (mid-2026)" or
523
- // "## TTP Mapping (MITRE ATT&CK Enterprise, mid-2026)"). The required
524
- // section name must appear as a leading token followed by end-of-string
525
- // or a non-alphanumeric character (paren, dash, colon).
435
+ // Case-insensitive, and tolerant of a trailing qualifier ("## Threat Context
436
+ // (mid-2026)"): the name must lead, then end-of-string or a non-alphanumeric.
526
437
  const findHeading = (title) => {
527
438
  const t = title.toLowerCase();
528
439
  return headings.find((h) => {
529
- // Required sections are top-level (H1/H2). A deeper heading — e.g. the H3
530
- // "### Compliance Theater Check Result" inside Output Format — must NOT
531
- // satisfy the standalone "## Compliance Theater Check" requirement.
440
+ // Required sections are H1/H2: a nested H3 "### Compliance Theater Check
441
+ // Result" must not satisfy the standalone "## Compliance Theater Check".
532
442
  if (h.depth > 2) return false;
533
443
  const lower = h.title.toLowerCase();
534
444
  if (lower === t) return true;
@@ -549,7 +459,6 @@ function findMissingSections(body, requiredSections) {
549
459
  missing.push(section);
550
460
  continue;
551
461
  }
552
- // Find the next heading at the same or shallower depth, or EOF.
553
462
  const idx = headings.indexOf(h);
554
463
  let endLine = lines.length;
555
464
  for (let j = idx + 1; j < headings.length; j++) {
@@ -676,11 +585,8 @@ function lintSkill(entry, ctx) {
676
585
  }
677
586
  }
678
587
 
679
- // L2 — attack_refs cross-catalog resolution. Surface as warnings by
680
- // default (preserving patch-class compatibility); promoted to hard
681
- // failures under --strict (the predeploy gate). If
682
- // data/attack-techniques.json is missing entirely the ctx.attackKeys set
683
- // is null — skip the check (the gate degrades gracefully).
588
+ // Unresolved attack_refs warn, and fail under --strict. ctx.attackKeys is
589
+ // null when data/attack-techniques.json is absent, and the check is skipped.
684
590
  if (Array.isArray(fm.attack_refs) && ctx.attackKeys) {
685
591
  for (const ref of fm.attack_refs) {
686
592
  if (!ctx.attackKeys.has(ref)) {
@@ -691,22 +597,9 @@ function lintSkill(entry, ctx) {
691
597
  }
692
598
  }
693
599
 
694
- // Hard Rule #1 enforcement at the skill-body layer. Every CVE-* /
695
- // MAL-* mentioned in skill prose MUST resolve to an entry in
696
- // data/cve-catalog.json. Hard Rule #1 ("no stale threat intel") is
697
- // enforced for catalog ENTRIES by lib/validate-cve-catalog.js — and
698
- // this body-scan extends it to the skill prose layer.
699
- //
700
- // v0.13.2 introduced this as a warning while the 2 pre-existing
701
- // violations (ransomware-response cited CVE-2024-21762,
702
- // cloud-iam-incident cited CVE-2026-21370) were triaged. v0.13.3
703
- // flips to hard error now that both have been resolved (the
704
- // Fortinet CVE landed in the catalog; the placeholder CVE was
705
- // removed from the cloud-iam-incident body).
706
- //
707
- // Draft references stay as warnings — operators promote drafts
708
- // on their own cadence and the catalog frequently carries
709
- // auto-imported drafts that skills can legitimately cite.
600
+ // Hard Rule #1 at the prose layer: every CVE-* / MAL-* cited in a skill body
601
+ // must resolve in data/cve-catalog.json. A `_draft: true` entry warns rather
602
+ // than failing — operators promote drafts on their own cadence.
710
603
  if (ctx.cveCatalog && body && typeof body === 'string') {
711
604
  const cveRefRe = /\b(CVE-\d{4}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
712
605
  const seen = new Set();
@@ -728,18 +621,11 @@ function lintSkill(entry, ctx) {
728
621
  }
729
622
  }
730
623
 
731
- // L3 — Defensive Countermeasure Mapping is required for skills reviewed
732
- // on or after COUNTERMEASURE_CUTOFF. Pre-cutoff skills are exempt. The
733
- // section's absence on a post-cutoff skill is a warning by default so
734
- // existing skills can add the section gradually; promoted to a hard
735
- // failure under --strict.
736
624
  const { missing, headerOnly } = findMissingSections(body, REQUIRED_SECTIONS);
737
625
  for (const s of missing) {
738
626
  skillErrors.push(`body: missing required section "${s}"`);
739
627
  }
740
628
  for (const ho of headerOnly) {
741
- // L1 — Header-only sections are warnings by default; promoted to a
742
- // failure under --strict.
743
629
  skillWarnings.push(
744
630
  `body: section "${ho.section}" has only ${ho.wordCount} words of body text (need >= ${MIN_SECTION_BODY_WORDS}); an error under --strict`,
745
631
  );
@@ -776,7 +662,6 @@ function loadContext() {
776
662
  const frameworks = readJson(FRAMEWORK_GAPS_PATH);
777
663
  const atlasKeys = new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')));
778
664
  const frameworkKeys = new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')));
779
- // Optional catalogs — load if present, otherwise treat as empty.
780
665
  function loadKeys(p) {
781
666
  const s = new Set();
782
667
  if (fs.existsSync(p)) {
@@ -785,20 +670,15 @@ function loadContext() {
785
670
  }
786
671
  return s;
787
672
  }
788
- // L2 — attack-techniques.json may not exist in older trees. When absent,
789
- // ctx.attackKeys is null and the L2 check is skipped.
673
+ // Null when data/attack-techniques.json is absent; the attack_refs check skips.
790
674
  let attackKeys = null;
791
675
  if (fs.existsSync(ATTACK_REFS_PATH)) {
792
676
  attackKeys = new Set();
793
677
  const j = readJson(ATTACK_REFS_PATH);
794
678
  for (const k of Object.keys(j)) if (!k.startsWith('_')) attackKeys.add(k);
795
679
  }
796
- // v0.13.2: load the CVE catalog into context so the Hard Rule #1
797
- // body-scan can resolve CVE-* / MAL-* references in skill prose
798
- // against the source-of-truth catalog. Loaded as a full object (not
799
- // just keys) so the body-scan can also surface `_draft: true` matches
800
- // as warnings rather than errors — operators promote drafts on their
801
- // own cadence.
680
+ // Loaded whole rather than as a key set, so the body scan can tell a missing
681
+ // CVE from a `_draft: true` one.
802
682
  const cveCatalog = fs.existsSync(CVE_CATALOG_PATH) ? readJson(CVE_CATALOG_PATH) : {};
803
683
 
804
684
  return {
@@ -814,25 +694,13 @@ function loadContext() {
814
694
  }
815
695
 
816
696
  /*
817
- * S6 — orphan skill.md detector.
818
- *
819
- * Walk every subdirectory of skills/ and assert each skill.md file is
820
- * referenced by exactly one manifest entry. Catches the v0.12.8
821
- * stash-restore class: a directory left behind on disk that nobody
822
- * signs because nobody listed it in the manifest, then the next
823
- * `npm pack` ships an unsigned skill (or worse, conflicts with a
824
- * future manifest entry of the same name).
825
- *
826
- * @param {Array<{path: string}>} manifestSkills
827
- * @returns {string[]} list of orphan filesystem paths (relative)
697
+ * Every skill.md under skills/ must be referenced by a manifest entry: a
698
+ * directory nobody listed is signed by nobody. Returns repo-relative paths.
828
699
  */
829
700
  function findOrphanSkillFiles(manifestSkills) {
830
701
  if (!fs.existsSync(SKILLS_DIR)) return [];
831
- // F19 — manifest paths are stored as forward-slash strings by contract
832
- // (lib/verify.js validateSkillPath() rejects backslashes). The previous
833
- // path.sep split was a no-op on Linux and incorrect on Windows when
834
- // mixed separators arrived through other ingest paths; the cleaner
835
- // contract is to normalise the comparison key directly.
702
+ // Manifest paths are forward-slash by contract (lib/verify.js validateSkillPath
703
+ // rejects backslashes), so normalise rather than splitting on path.sep.
836
704
  const referenced = new Set(
837
705
  manifestSkills.map((s) => String(s.path).replace(/\\/g, '/')),
838
706
  );
@@ -848,17 +716,9 @@ function findOrphanSkillFiles(manifestSkills) {
848
716
  return orphans;
849
717
  }
850
718
 
851
- // Manifest cover arrays that must resolve to a real catalog entry, paired
852
- // with the loadContext() key-set they resolve against. The manifest is an
853
- // enriched superset of frontmatter (it may carry curated refs absent from
854
- // any skill body), and those manifest-only refs are what build-indexes'
855
- // reverse-ref surface and refresh-reverse-refs read — yet the per-skill
856
- // frontmatter ref-resolution above never sees them. Without this pass a
857
- // typo'd or stale manifest-only ref (e.g. a hand-edit, or one re-signed
858
- // into manifest_signature) becomes an orphaned control reference in the
859
- // signed manifest + every derived surface, the exact "no orphaned controls"
860
- // failure (AGENTS.md Hard Rule #4) the frontmatter resolution prevents —
861
- // applied to the manifest-only delta the frontmatter pass is blind to.
719
+ // Manifest cover arrays that must resolve to a real catalog entry, paired with
720
+ // the loadContext() key set. These manifest-only refs feed build-indexes'
721
+ // reverse-ref surface and refresh-reverse-refs; the frontmatter pass never sees them.
862
722
  const MANIFEST_COVER_RESOLUTION = [
863
723
  { field: 'atlas_refs', ctxKey: 'atlasKeys', catalog: 'data/atlas-ttps.json' },
864
724
  { field: 'attack_refs', ctxKey: 'attackKeys', catalog: 'data/attack-techniques.json' },
@@ -870,18 +730,9 @@ const MANIFEST_COVER_RESOLUTION = [
870
730
  ];
871
731
 
872
732
  /*
873
- * Manifest cover-array ref resolution. For every manifest skill entry,
874
- * assert each ref in its cross-reference cover arrays resolves to a
875
- * top-level non-underscore key in the matching catalog. This extends the
876
- * per-skill frontmatter ref-resolution (which reads only skill bodies) to
877
- * the MANIFEST cover arrays — the signed, index-feeding source of truth
878
- * that build-indexes' reverse-ref surface and refresh-reverse-refs read.
879
- *
880
- * A ctxKey set that is null (the optional attack-techniques.json catalog is
881
- * absent in older trees) skips that field, mirroring loadContext()'s
882
- * graceful-degradation contract for ctx.attackKeys.
883
- *
884
- * @returns {string[]} `<skill>.<field>: <ref> not present in <catalog>` lines
733
+ * Assert every ref in each manifest entry's cover arrays resolves in the
734
+ * matching catalog. A null ctx key set — an absent optional catalog — skips
735
+ * that field.
885
736
  */
886
737
  function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
887
738
  const errors = [];
@@ -901,11 +752,8 @@ function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
901
752
  return errors;
902
753
  }
903
754
 
904
- // Substrings that indicate an artifact `source` makes a network call. Used
905
- // by lintPlaybookAirGap() to flag artifacts that lack an air_gap_alternative.
906
- // Conservative-by-design — false positives are surfaced as `warn` (not
907
- // `error`) and a playbook author who has reviewed the source can suppress
908
- // by adding an air_gap_alternative even when the source itself is offline.
755
+ // Substrings that mark an artifact `source` as a network call. Deliberately
756
+ // broad: a hit only warns, and an air_gap_alternative silences it.
909
757
  const PLAYBOOK_NET_PATTERNS = [
910
758
  'https://', 'http://', 'gh api', 'gh release', 'curl ', 'wget ', 'fetch ',
911
759
  ];
@@ -913,16 +761,10 @@ const PLAYBOOK_NET_PATTERNS = [
913
761
  const PLAYBOOK_DIR = path.join(DATA_DIR, 'playbooks');
914
762
 
915
763
  /**
916
- * Air-gap completeness lint for shipped playbooks. Walks every
917
- * data/playbooks/*.json file, examines phases.look.artifacts[], and warns
918
- * when an artifact's `source` contains a network-call substring without a
919
- * sibling `air_gap_alternative`. The playbook schema's hard `if/then`
920
- * conditional (added v0.12.24) catches this for playbooks marked
921
- * `_meta.air_gap_mode: true`; this lint surfaces the gap for every
922
- * playbook, on the principle that a non-air-gap playbook may still be
923
- * invoked under `exceptd --air-gap` and operators deserve the warning.
924
- *
925
- * Returns an array of `{ playbook, artifact_id, source }` warning records.
764
+ * Warn on any data/playbooks/*.json look artifact whose `source` makes a network
765
+ * call without a sibling `air_gap_alternative`. The schema enforces that only for
766
+ * `_meta.air_gap_mode: true`, but any playbook can run under `--air-gap`. Returns
767
+ * `{ playbook, artifact_id, source }` records.
926
768
  */
927
769
  function lintPlaybookAirGap() {
928
770
  const warnings = [];
@@ -995,9 +837,7 @@ function main() {
995
837
  }
996
838
  }
997
839
 
998
- // S6 — orphan check runs only on a full lint pass (no --skill filter).
999
- // A targeted single-skill lint is for diagnosing one entry; running
1000
- // the orphan walk there would surface unrelated findings.
840
+ // These passes run only on a full lint; a --skill run would show unrelated noise.
1001
841
  let orphans = [];
1002
842
  let manifestRefErrors = [];
1003
843
  let airGapWarnings = [];
@@ -1008,19 +848,14 @@ function main() {
1008
848
  console.log(` - skill.md exists on disk but not in manifest: ${o}`);
1009
849
  console.log(` fix: re-run sign-all (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) after adding it to manifest.json, OR delete the orphan directory`);
1010
850
  }
1011
- // Manifest cover-array ref resolution (Hard Rule #4 — no orphaned
1012
- // controls). The per-skill pass above resolves frontmatter refs; this
1013
- // resolves the manifest cover arrays the reverse-ref surface reads, so a
1014
- // typo'd/stale manifest-only ref can't ship as an orphaned control. A
1015
- // hard failure (not a --strict warning): an unresolved control ref is
1016
- // unconditionally wrong, matching the frontmatter ref-resolution voice.
851
+ // Hard Rule #4, no orphaned controls: an unresolved control ref is
852
+ // unconditionally wrong, so this fails rather than warning under --strict.
1017
853
  manifestRefErrors = findUnresolvedManifestCoverRefs(manifest.skills, ctx);
1018
854
  for (const e of manifestRefErrors) {
1019
855
  console.log(`FAIL <manifest-cover-ref>`);
1020
856
  console.log(` - ${e}`);
1021
857
  console.log(` fix: correct the typo'd/stale ref in manifest.json (or add the entry to the catalog), then re-run sign-all + refresh-reverse-refs + build-indexes`);
1022
858
  }
1023
- // P4 — air-gap completeness lint over data/playbooks/*.json.
1024
859
  airGapWarnings = lintPlaybookAirGap();
1025
860
  for (const w of airGapWarnings) {
1026
861
  console.log(`WARN playbook:${w.playbook}`);
@@ -1043,8 +878,6 @@ function main() {
1043
878
  console.log(
1044
879
  `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${manifestRefSummary}${airGapSummary}.`,
1045
880
  );
1046
- // --strict treats any warning (per-skill or playbook air-gap) as a
1047
- // release-blocking failure so a warned regression cannot ship silently.
1048
881
  const strictFail = opts.strict && (warned > 0 || (airGapWarnings && airGapWarnings.length > 0));
1049
882
  if (strictFail) {
1050
883
  console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
@@ -1053,8 +886,7 @@ function main() {
1053
886
  return;
1054
887
  }
1055
888
 
1056
- // Export the minimal frontmatter parser for downstream consumers
1057
- // (e.g., orchestrator `watchlist` command) so they don't reinvent it.
889
+ // The frontmatter parser is exported so `watchlist` does not grow a second one.
1058
890
  module.exports = {
1059
891
  parseFrontmatter,
1060
892
  extractFrontmatterBlock,