@blamejs/exceptd-skills 0.19.33 → 0.19.35

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 (119) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/exceptd.js +895 -2828
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +170 -76
  8. package/lib/collectors/cicd-pipeline-compromise.js +113 -136
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +198 -211
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +130 -118
  20. package/lib/collectors/scan-excludes.js +33 -139
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -155
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +39 -113
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +88 -236
  37. package/lib/playbook-runner.js +759 -2107
  38. package/lib/prefetch.js +101 -376
  39. package/lib/refresh-external.js +199 -633
  40. package/lib/refresh-network.js +78 -311
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +85 -146
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +28 -27
  48. package/lib/upstream-check-cli.js +36 -29
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +52 -121
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +78 -286
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -413
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +242 -242
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +29 -28
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +21 -31
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +26 -57
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +63 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +62 -81
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +83 -198
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +7 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +7 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +7 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +63 -148
  114. package/scripts/release.js +69 -234
  115. package/scripts/run-e2e-scenarios.js +26 -73
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -141
@@ -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;
@@ -140,8 +100,20 @@ function isStrictIsoCalendarDate(s) {
140
100
  const KEBAB_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
141
101
  const JSON_FILENAME_RE = /^[A-Za-z0-9._-]+\.json$/;
142
102
 
103
+ // `helpExitCode` is null unless the run is over before linting starts: 0 after
104
+ // --help, 2 after an unknown argument. parseArgs never terminates the process —
105
+ // it returns the code and the caller must honour it and stop.
106
+ //
107
+ // The invariant this keeps: every exit in this file goes through safeExit(), so
108
+ // no path here can write to stdout and then terminate synchronously. That is the
109
+ // `process-exit-after-stdout-write` shape, and it is a convention held file-wide
110
+ // rather than a repair of an observed truncation: Node writes pipe stdout
111
+ // synchronously on Windows and Linux (asynchronously on macOS), and the help
112
+ // block is a few hundred bytes, so process.exit() did not in fact truncate it
113
+ // here. Holding the invariant means a future help block that grows, or a run on
114
+ // a platform with async pipe writes, cannot reintroduce the class.
143
115
  function parseArgs(argv) {
144
- const opts = { skill: null, quiet: false, strict: false };
116
+ const opts = { skill: null, quiet: false, strict: false, helpExitCode: null };
145
117
  for (let i = 2; i < argv.length; i++) {
146
118
  const a = argv[i];
147
119
  if (a === '--skill') {
@@ -151,17 +123,17 @@ function parseArgs(argv) {
151
123
  } else if (a === '--quiet' || a === '-q') {
152
124
  opts.quiet = true;
153
125
  } 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.
126
+ // The predeploy gate runs --strict, so a warned regression cannot scroll past.
157
127
  opts.strict = true;
158
128
  } else if (a === '--help' || a === '-h') {
159
129
  printHelp();
160
- process.exit(0);
130
+ opts.helpExitCode = 0;
131
+ return opts;
161
132
  } else {
162
133
  console.error(`Unknown argument: ${a}`);
163
134
  printHelp();
164
- process.exit(2);
135
+ opts.helpExitCode = 2;
136
+ return opts;
165
137
  }
166
138
  }
167
139
  return opts;
@@ -183,30 +155,16 @@ function readJson(p) {
183
155
  }
184
156
 
185
157
  /*
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.
158
+ * Minimal YAML frontmatter parser: quoted or bare scalars, `[]`, and indented
159
+ * `- ` item lists. Anything outside that shape throws.
196
160
  */
197
161
  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.
162
+ // A dangling `\r` survives on the final frontmatter line (the close marker took
163
+ // the `\n`), and `.` does not match `\r`, so the per-line regex would fail.
203
164
  const lines = text.split(/\r?\n/).map((l) => l.replace(/\r$/, ''));
204
165
  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.
166
+ // YAML's last-wins semantics would let "name: real\nname: evil" take the
167
+ // second value — a skill-identity spoof — so a duplicate key is refused.
210
168
  const seenKeys = new Set();
211
169
  let i = 0;
212
170
  while (i < lines.length) {
@@ -261,11 +219,8 @@ function unquote(s) {
261
219
  if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
262
220
  return s.slice(1, -1);
263
221
  }
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.
222
+ // A quoted scalar trailed by an inline comment (`"standalone" # why`). Only
223
+ // when the tail is whitespace + `#`, so a `#` inside a value is not a comment.
269
224
  if (first === '"' || first === "'") {
270
225
  const close = s.indexOf(first, 1);
271
226
  if (close > 0) {
@@ -294,18 +249,12 @@ function extractFrontmatterBlock(content) {
294
249
  return { frontmatter: raw.replace(/^\r?\n/, ''), body: bodyStart, frontmatterRaw: raw };
295
250
  }
296
251
 
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.
252
+ // Fields with a dedicated regex and error wording above; the schema-driven pass
253
+ // skips them so a field is never reported twice.
300
254
  const SCHEMA_PATTERN_HANDLED_ELSEWHERE = new Set(['atlas_refs', 'attack_refs', 'data_deps']);
301
255
 
302
256
  /* 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. */
257
+ * lib/schemas/skill-frontmatter.schema.json. */
309
258
  function schemaConstraintErrors(fm, schema) {
310
259
  const errors = [];
311
260
  const props = (schema && schema.properties) || {};
@@ -313,11 +262,8 @@ function schemaConstraintErrors(fm, schema) {
313
262
  if (!(field in fm)) continue;
314
263
  const value = fm[field];
315
264
 
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.
265
+ // Type first, membership only on a string: guarding on `typeof value ===
266
+ // 'string'` would let `discovery_mode: [standalone]` through unvalidated.
321
267
  if (Array.isArray(spec.enum)) {
322
268
  if (typeof value !== 'string') {
323
269
  errors.push(
@@ -350,13 +296,10 @@ function schemaConstraintErrors(fm, schema) {
350
296
  return errors;
351
297
  }
352
298
 
353
- /* Validate frontmatter object against the codified schema rules. */
299
+ /* Returns { errors, warnings } — the review-staleness soft cap warns without
300
+ * failing, so callers must read both arrays. */
354
301
  function validateFrontmatter(fm, skillName) {
355
302
  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
303
  const warnings = [];
361
304
 
362
305
  for (const key of Object.keys(fm)) {
@@ -467,10 +410,6 @@ function validateFrontmatter(fm, skillName) {
467
410
  `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
411
  );
469
412
  } 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
413
  const days = Math.floor((Date.now() - Date.parse(fm.last_threat_review + 'T00:00:00Z')) / (24 * 60 * 60 * 1000));
475
414
  if (days > 365) {
476
415
  errors.push(
@@ -484,30 +423,19 @@ function validateFrontmatter(fm, skillName) {
484
423
  }
485
424
  }
486
425
 
487
- // Drive enum + ref-pattern enforcement from the published schema so the
488
- // shipped artifact and this gate cannot diverge.
489
426
  errors.push(...schemaConstraintErrors(fm, FRONTMATTER_SCHEMA));
490
427
 
491
428
  return { errors, warnings };
492
429
  }
493
430
 
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. */
431
+ /* Returns { missing, headerOnly }: sections with no heading in the body, and
432
+ * sections whose heading exists but whose text runs shorter than
433
+ * MIN_SECTION_BODY_WORDS. */
503
434
  function findMissingSections(body, requiredSections) {
504
435
  const sections = requiredSections || REQUIRED_SECTIONS;
505
436
  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).
437
+ // Headings inside a fenced block are documentation: counting them lets a skill
438
+ // whose sections appear only inside a fence pass the gate vacuously.
511
439
  const headings = [];
512
440
  let inFence = false;
513
441
  for (let i = 0; i < lines.length; i++) {
@@ -518,17 +446,13 @@ function findMissingSections(body, requiredSections) {
518
446
  headings.push({ line: i, depth: m[1].length, title: m[2].trim() });
519
447
  }
520
448
  }
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).
449
+ // Case-insensitive, and tolerant of a trailing qualifier ("## Threat Context
450
+ // (mid-2026)"): the name must lead, then end-of-string or a non-alphanumeric.
526
451
  const findHeading = (title) => {
527
452
  const t = title.toLowerCase();
528
453
  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.
454
+ // Required sections are H1/H2: a nested H3 "### Compliance Theater Check
455
+ // Result" must not satisfy the standalone "## Compliance Theater Check".
532
456
  if (h.depth > 2) return false;
533
457
  const lower = h.title.toLowerCase();
534
458
  if (lower === t) return true;
@@ -549,7 +473,6 @@ function findMissingSections(body, requiredSections) {
549
473
  missing.push(section);
550
474
  continue;
551
475
  }
552
- // Find the next heading at the same or shallower depth, or EOF.
553
476
  const idx = headings.indexOf(h);
554
477
  let endLine = lines.length;
555
478
  for (let j = idx + 1; j < headings.length; j++) {
@@ -676,11 +599,8 @@ function lintSkill(entry, ctx) {
676
599
  }
677
600
  }
678
601
 
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).
602
+ // Unresolved attack_refs warn, and fail under --strict. ctx.attackKeys is
603
+ // null when data/attack-techniques.json is absent, and the check is skipped.
684
604
  if (Array.isArray(fm.attack_refs) && ctx.attackKeys) {
685
605
  for (const ref of fm.attack_refs) {
686
606
  if (!ctx.attackKeys.has(ref)) {
@@ -691,22 +611,9 @@ function lintSkill(entry, ctx) {
691
611
  }
692
612
  }
693
613
 
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.
614
+ // Hard Rule #1 at the prose layer: every CVE-* / MAL-* cited in a skill body
615
+ // must resolve in data/cve-catalog.json. A `_draft: true` entry warns rather
616
+ // than failing — operators promote drafts on their own cadence.
710
617
  if (ctx.cveCatalog && body && typeof body === 'string') {
711
618
  const cveRefRe = /\b(CVE-\d{4}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
712
619
  const seen = new Set();
@@ -728,18 +635,11 @@ function lintSkill(entry, ctx) {
728
635
  }
729
636
  }
730
637
 
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
638
  const { missing, headerOnly } = findMissingSections(body, REQUIRED_SECTIONS);
737
639
  for (const s of missing) {
738
640
  skillErrors.push(`body: missing required section "${s}"`);
739
641
  }
740
642
  for (const ho of headerOnly) {
741
- // L1 — Header-only sections are warnings by default; promoted to a
742
- // failure under --strict.
743
643
  skillWarnings.push(
744
644
  `body: section "${ho.section}" has only ${ho.wordCount} words of body text (need >= ${MIN_SECTION_BODY_WORDS}); an error under --strict`,
745
645
  );
@@ -776,7 +676,6 @@ function loadContext() {
776
676
  const frameworks = readJson(FRAMEWORK_GAPS_PATH);
777
677
  const atlasKeys = new Set(Object.keys(atlas).filter((k) => !k.startsWith('_')));
778
678
  const frameworkKeys = new Set(Object.keys(frameworks).filter((k) => !k.startsWith('_')));
779
- // Optional catalogs — load if present, otherwise treat as empty.
780
679
  function loadKeys(p) {
781
680
  const s = new Set();
782
681
  if (fs.existsSync(p)) {
@@ -785,20 +684,15 @@ function loadContext() {
785
684
  }
786
685
  return s;
787
686
  }
788
- // L2 — attack-techniques.json may not exist in older trees. When absent,
789
- // ctx.attackKeys is null and the L2 check is skipped.
687
+ // Null when data/attack-techniques.json is absent; the attack_refs check skips.
790
688
  let attackKeys = null;
791
689
  if (fs.existsSync(ATTACK_REFS_PATH)) {
792
690
  attackKeys = new Set();
793
691
  const j = readJson(ATTACK_REFS_PATH);
794
692
  for (const k of Object.keys(j)) if (!k.startsWith('_')) attackKeys.add(k);
795
693
  }
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.
694
+ // Loaded whole rather than as a key set, so the body scan can tell a missing
695
+ // CVE from a `_draft: true` one.
802
696
  const cveCatalog = fs.existsSync(CVE_CATALOG_PATH) ? readJson(CVE_CATALOG_PATH) : {};
803
697
 
804
698
  return {
@@ -814,25 +708,13 @@ function loadContext() {
814
708
  }
815
709
 
816
710
  /*
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)
711
+ * Every skill.md under skills/ must be referenced by a manifest entry: a
712
+ * directory nobody listed is signed by nobody. Returns repo-relative paths.
828
713
  */
829
714
  function findOrphanSkillFiles(manifestSkills) {
830
715
  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.
716
+ // Manifest paths are forward-slash by contract (lib/verify.js validateSkillPath
717
+ // rejects backslashes), so normalise rather than splitting on path.sep.
836
718
  const referenced = new Set(
837
719
  manifestSkills.map((s) => String(s.path).replace(/\\/g, '/')),
838
720
  );
@@ -848,17 +730,9 @@ function findOrphanSkillFiles(manifestSkills) {
848
730
  return orphans;
849
731
  }
850
732
 
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.
733
+ // Manifest cover arrays that must resolve to a real catalog entry, paired with
734
+ // the loadContext() key set. These manifest-only refs feed build-indexes'
735
+ // reverse-ref surface and refresh-reverse-refs; the frontmatter pass never sees them.
862
736
  const MANIFEST_COVER_RESOLUTION = [
863
737
  { field: 'atlas_refs', ctxKey: 'atlasKeys', catalog: 'data/atlas-ttps.json' },
864
738
  { field: 'attack_refs', ctxKey: 'attackKeys', catalog: 'data/attack-techniques.json' },
@@ -870,18 +744,9 @@ const MANIFEST_COVER_RESOLUTION = [
870
744
  ];
871
745
 
872
746
  /*
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
747
+ * Assert every ref in each manifest entry's cover arrays resolves in the
748
+ * matching catalog. A null ctx key set — an absent optional catalog — skips
749
+ * that field.
885
750
  */
886
751
  function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
887
752
  const errors = [];
@@ -901,11 +766,8 @@ function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
901
766
  return errors;
902
767
  }
903
768
 
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.
769
+ // Substrings that mark an artifact `source` as a network call. Deliberately
770
+ // broad: a hit only warns, and an air_gap_alternative silences it.
909
771
  const PLAYBOOK_NET_PATTERNS = [
910
772
  'https://', 'http://', 'gh api', 'gh release', 'curl ', 'wget ', 'fetch ',
911
773
  ];
@@ -913,16 +775,10 @@ const PLAYBOOK_NET_PATTERNS = [
913
775
  const PLAYBOOK_DIR = path.join(DATA_DIR, 'playbooks');
914
776
 
915
777
  /**
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.
778
+ * Warn on any data/playbooks/*.json look artifact whose `source` makes a network
779
+ * call without a sibling `air_gap_alternative`. The schema enforces that only for
780
+ * `_meta.air_gap_mode: true`, but any playbook can run under `--air-gap`. Returns
781
+ * `{ playbook, artifact_id, source }` records.
926
782
  */
927
783
  function lintPlaybookAirGap() {
928
784
  const warnings = [];
@@ -956,6 +812,10 @@ function lintPlaybookAirGap() {
956
812
 
957
813
  function main() {
958
814
  const opts = parseArgs(process.argv);
815
+ if (opts.helpExitCode !== null) {
816
+ safeExit(opts.helpExitCode);
817
+ return;
818
+ }
959
819
  const manifest = readJson(MANIFEST_PATH);
960
820
 
961
821
  let skills = manifest.skills;
@@ -963,7 +823,8 @@ function main() {
963
823
  skills = skills.filter((s) => s.name === opts.skill);
964
824
  if (skills.length === 0) {
965
825
  console.error(`No skill named "${opts.skill}" in manifest.json`);
966
- process.exit(2);
826
+ safeExit(2);
827
+ return;
967
828
  }
968
829
  }
969
830
 
@@ -995,9 +856,7 @@ function main() {
995
856
  }
996
857
  }
997
858
 
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.
859
+ // These passes run only on a full lint; a --skill run would show unrelated noise.
1001
860
  let orphans = [];
1002
861
  let manifestRefErrors = [];
1003
862
  let airGapWarnings = [];
@@ -1008,19 +867,14 @@ function main() {
1008
867
  console.log(` - skill.md exists on disk but not in manifest: ${o}`);
1009
868
  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
869
  }
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.
870
+ // Hard Rule #4, no orphaned controls: an unresolved control ref is
871
+ // unconditionally wrong, so this fails rather than warning under --strict.
1017
872
  manifestRefErrors = findUnresolvedManifestCoverRefs(manifest.skills, ctx);
1018
873
  for (const e of manifestRefErrors) {
1019
874
  console.log(`FAIL <manifest-cover-ref>`);
1020
875
  console.log(` - ${e}`);
1021
876
  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
877
  }
1023
- // P4 — air-gap completeness lint over data/playbooks/*.json.
1024
878
  airGapWarnings = lintPlaybookAirGap();
1025
879
  for (const w of airGapWarnings) {
1026
880
  console.log(`WARN playbook:${w.playbook}`);
@@ -1043,8 +897,6 @@ function main() {
1043
897
  console.log(
1044
898
  `\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${manifestRefSummary}${airGapSummary}.`,
1045
899
  );
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
900
  const strictFail = opts.strict && (warned > 0 || (airGapWarnings && airGapWarnings.length > 0));
1049
901
  if (strictFail) {
1050
902
  console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
@@ -1053,9 +905,9 @@ function main() {
1053
905
  return;
1054
906
  }
1055
907
 
1056
- // Export the minimal frontmatter parser for downstream consumers
1057
- // (e.g., orchestrator `watchlist` command) so they don't reinvent it.
908
+ // The frontmatter parser is exported so `watchlist` does not grow a second one.
1058
909
  module.exports = {
910
+ parseArgs,
1059
911
  parseFrontmatter,
1060
912
  extractFrontmatterBlock,
1061
913
  unquote,