@blamejs/exceptd-skills 0.19.33 → 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 (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  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 +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  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 +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  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 -152
  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 +35 -114
  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 +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  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 +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  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 -412
  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 +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  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 +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  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 +50 -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 +13 -73
  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 +66 -197
  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 +3 -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 +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  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 -140
@@ -2,20 +2,8 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * exceptd orchestrator — CLI entry point.
6
- *
7
- * Commands:
8
- * scan Scan current environment and produce findings
9
- * dispatch Route findings to relevant skills
10
- * skill <name> Show context for a specific skill
11
- * pipeline Initialize and describe a pipeline run
12
- * currency Check skill currency scores
13
- * report Print dispatch plan as a report
14
- * watch Start event bus watcher (long-running)
15
- * validate-cves Remind to validate CVE entries against NVD
16
- * validate-rfcs Cross-check the RFC catalog against IETF Datatracker
17
- * watchlist Aggregate forward_watch entries across all skills
18
- * help Show this help
5
+ * exceptd orchestrator — CLI entry point. printHelp() below is the
6
+ * authoritative verb and flag list.
19
7
  */
20
8
 
21
9
  const fsMod = require('fs');
@@ -34,16 +22,9 @@ const cmd = process.argv[2];
34
22
  const args = process.argv.slice(3);
35
23
 
36
24
  /**
37
- * Minimal argv parser shared by verbs that want a structured view of
38
- * `process.argv.slice(2)` instead of repeated `.includes()` calls. Returns
39
- * `{ flags: Set<string>, options: Map<string,string>, positionals: string[] }`
40
- * where boolean flags (e.g. `--json`) land in `flags`, option flags with a
41
- * value (e.g. `--log-file path.log` or `--log-file=path.log`) land in
42
- * `options`, and the rest are positionals.
43
- *
44
- * Kept inline rather than reaching into `lib/refresh-external.js#parseArgs`
45
- * because that helper is hard-coded to the `refresh` flag set; this one
46
- * stays generic. Both follow the same convention so verbs stay consistent.
25
+ * Returns `{ flags: Set<string>, options: Map<string,string>, positionals:
26
+ * string[] }`. A flag named in `optionFlags` consumes the following token (or an
27
+ * `=value`) into `options`; every other `--flag` is a boolean in `flags`.
47
28
  */
48
29
  function parseFlags(argv, optionFlags) {
49
30
  const optSet = new Set(optionFlags || []);
@@ -90,10 +71,8 @@ async function main() {
90
71
  runSkillContext(args);
91
72
  break;
92
73
  case 'pipeline': {
93
- // pipeline is not dispatched by the bin/ CLI; it's reachable only via a
94
- // direct orchestrator invocation. Guard the findings JSON.parse so
95
- // malformed input emits a structured ok:false envelope instead of an
96
- // uncaught SyntaxError stack trace.
74
+ // Reachable only through a direct orchestrator invocation. The guarded
75
+ // parse turns malformed findings into an ok:false envelope.
97
76
  let findings = {};
98
77
  if (args[1]) {
99
78
  try {
@@ -115,9 +94,8 @@ async function main() {
115
94
  runCurrency();
116
95
  break;
117
96
  case 'report':
118
- // Resolve the format from the first NON-FLAG positional so `report --json`
119
- // (no format given) defaults to technical instead of treating "--json" as
120
- // an invalid format and exiting 1.
97
+ // The first non-flag positional, so `report --json` does not read "--json"
98
+ // as the format.
121
99
  await runReport(args.find((a) => typeof a === 'string' && !a.startsWith('--')) || 'technical');
122
100
  break;
123
101
  case 'watch':
@@ -142,26 +120,12 @@ async function main() {
142
120
  }
143
121
  }
144
122
 
145
- // Programmatic runner for the framework-gap-analysis skill. Closes the
146
- // dispatch loop — previously `exceptd dispatch` would say "run
147
- // framework-gap-analysis" but the only thing the CLI could actually do
148
- // was print the skill body. This subcommand executes the analytical
149
- // path in lib/framework-gap.js so an operator (or a CI gate) can pipe
150
- // the JSON into another tool.
151
- //
152
- // Usage:
153
- // exceptd framework-gap <FRAMEWORK_ID|all> <SCENARIO|CVE-ID>
154
- // exceptd framework-gap NIST-800-53 CVE-2026-31431
155
- // exceptd framework-gap PCI-DSS-4.0 "prompt injection"
156
- // exceptd framework-gap all CVE-2025-53773 --json
157
123
  function runFrameworkGap(rawArgs) {
158
124
  const fs = require('fs');
159
125
  const path = require('path');
160
126
  const { gapReport, theaterCheck } = require('../lib/framework-gap');
161
127
 
162
- // Reject unknown flags with the shared structured envelope. framework-gap
163
- // consumes only --json; the global air-gap flags are accepted-and-ignored
164
- // (the analytical path reads local catalogs, no egress).
128
+ // Air-gap flags are accepted and ignored: this path reads local catalogs only.
165
129
  if (rejectUnknownFlags('framework-gap', rawArgs, ['--json', '--air-gap', '--offline', '--no-network'])) return;
166
130
  const args = rawArgs.filter(a => !a.startsWith('--'));
167
131
  const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
@@ -173,8 +137,7 @@ Examples:
173
137
  exceptd framework-gap NIST-800-53 CVE-2026-31431
174
138
  exceptd framework-gap PCI-DSS-4.0 "prompt injection"
175
139
  exceptd framework-gap all CVE-2025-53773 --json`;
176
- // Honor --json on the missing-arg path: a JSON consumer must get a
177
- // structured ok:false envelope, not plain usage text on stderr.
140
+ // A JSON consumer needs the ok:false envelope, not usage text on stderr.
178
141
  if (jsonOut) {
179
142
  process.stdout.write(JSON.stringify({
180
143
  ok: false,
@@ -185,10 +148,8 @@ Examples:
185
148
  } else {
186
149
  console.error(usage);
187
150
  }
188
- // v0.13 exit-code class fix: usage error is GENERIC_FAILURE (1),
189
- // not DETECTED_ESCALATE (2). Pre-v0.13 the orchestrator emitted
190
- // exit 2 for usage errors, colliding with CI gates that branch on
191
- // exit 2 to mean "verb ran + detected escalation-worthy finding".
151
+ // A usage error is GENERIC_FAILURE, never DETECTED_ESCALATE — exit 2 tells a
152
+ // CI gate the verb ran and found something.
192
153
  safeExit(EXIT_CODES.GENERIC_FAILURE);
193
154
  return;
194
155
  }
@@ -200,8 +161,6 @@ Examples:
200
161
  cveCatalog = JSON.parse(fs.readFileSync(path.join(root, 'data', 'cve-catalog.json'), 'utf8'));
201
162
  lessons = JSON.parse(fs.readFileSync(path.join(root, 'data', 'zeroday-lessons.json'), 'utf8'));
202
163
  } catch (err) {
203
- // Honor --json on the catalog-read failure path too: a JSON consumer must
204
- // get the same structured ok:false envelope the other error exits emit.
205
164
  const msg = `framework-gap: cannot read catalog: ${err.message}`;
206
165
  if (jsonOut) {
207
166
  process.stdout.write(JSON.stringify({ ok: false, verb: 'framework-gap', error: msg }) + '\n');
@@ -212,8 +171,6 @@ Examples:
212
171
  return;
213
172
  }
214
173
 
215
- // The set of framework IDs the catalog actually carries gaps for. Used both
216
- // to expand `all` and to validate an explicit framework argument.
217
174
  const knownFrameworks = [...new Set(Object.values(controlGaps).flatMap(g =>
218
175
  Array.isArray(g.framework) ? g.framework : [g.framework]
219
176
  ).filter(f => f && f !== 'ALL'))];
@@ -222,18 +179,9 @@ Examples:
222
179
  ? knownFrameworks
223
180
  : [args[0]];
224
181
 
225
- // Validate an explicit framework name. Pre-fix an unknown framework (typo,
226
- // wrong casing, a framework the catalog doesn't track) produced a report
227
- // with zero matching gaps — indistinguishable from a real "no gaps" result,
228
- // so an operator could read a typo as proof the framework covers the
229
- // scenario. Refuse with the known-framework list instead.
230
- //
231
- // Match exactly as gapReport does: normalize (strip case + spaces + hyphens)
232
- // and accept a substring hit against either a gap's `framework` or a prefix
233
- // hit against a gap KEY — so the documented short forms ("NIST-800-53"
234
- // matching "NIST 800-53 Rev 5") still resolve. A framework is "known" when at
235
- // least one catalog gap matches it, independent of the scenario; a known
236
- // framework with no scenario gaps remains a legitimate empty result.
182
+ // An unknown framework reports zero matching gaps — indistinguishable from a
183
+ // real "no gaps" result, so a typo could read as proof of coverage. Matching is
184
+ // as gapReport does it, so the short forms still resolve.
237
185
  if (args[0].toLowerCase() !== 'all') {
238
186
  const normalize = (s) => String(s).toLowerCase().replace(/[\s_-]/g, '');
239
187
  const idNorm = normalize(args[0]);
@@ -263,7 +211,6 @@ Examples:
263
211
  return;
264
212
  }
265
213
 
266
- // Human-readable output.
267
214
  console.log(`\nFramework gap analysis — ${new Date().toISOString().slice(0, 10)}`);
268
215
  console.log(`Scenario: ${scenario}`);
269
216
  console.log(`Frameworks: ${requested.join(', ')}\n`);
@@ -292,8 +239,6 @@ Examples:
292
239
  for (const c of report.new_control_requirements) {
293
240
  const req = c.requirement || '';
294
241
  console.log(` - ${c.id} ${c.name}: ${req.length > 140 ? req.slice(0, 140) + '…' : req}`);
295
- // Naming the gaps it closes is what separates this from generic advice —
296
- // it ties the control back to the insufficient framework controls above.
297
242
  if (c.closes.length) console.log(` closes: ${c.closes.join(', ')}`);
298
243
  }
299
244
  console.log();
@@ -310,25 +255,16 @@ Examples:
310
255
  console.log(`Summary: ${report.summary.total_gaps} matching gaps, ${report.summary.universal_gaps} universal, ${report.summary.new_control_requirements} new controls required, ${report.summary.theater_risk_controls} theater-risk controls`);
311
256
  }
312
257
 
313
- // --- command implementations ---
314
-
315
258
  async function runScan() {
316
- // Use the shared parseFlags helper so flag handling is consistent with
317
- // other verbs (validate-cves, watchlist, etc.). Previously this was a
318
- // bare `process.argv.includes('--json')`, which differed in style from
319
- // the verbs below and could miss `--json=true` or similar future forms.
320
- // --air-gap / --offline / --no-network are global flags; scan's TLS-reachability
321
- // probe is the one network touch, and --air-gap (or EXCEPTD_AIR_GAP=1) now
322
- // suppresses it (see scanner.js isAirGap), so the flags are accepted here and
323
- // honored downstream rather than silently ignored.
259
+ // scan's TLS-reachability probe is its one network touch; --air-gap (or
260
+ // EXCEPTD_AIR_GAP=1) suppresses it in scanner.js, so the flags are honored there.
324
261
  if (rejectUnknownFlags('scan', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
325
262
  const { flags } = parseFlags(process.argv.slice(2), []);
326
263
  const jsonOut = flags.has('--json');
327
264
  if (!jsonOut) console.log('[orchestrator] Scanning environment...\n');
328
265
  const result = await scan();
329
266
  if (jsonOut) {
330
- // Top-level ok:true so a single JSON consumer can branch on the same
331
- // envelope the ok:false error paths emit.
267
+ // ok:true so one consumer branches on the same envelope as the error paths.
332
268
  process.stdout.write(JSON.stringify({ ok: true, ...result }) + '\n');
333
269
  return result;
334
270
  }
@@ -359,9 +295,7 @@ async function runScan() {
359
295
  }
360
296
 
361
297
  async function runDispatch() {
362
- // --air-gap / --offline / --no-network: dispatch runs scan() first, whose TLS
363
- // probe is suppressed under air-gap (see runScan / scanner.js isAirGap), so the
364
- // flags are accepted here and honored downstream.
298
+ // dispatch runs scan(), whose TLS probe is suppressed under air-gap.
365
299
  if (rejectUnknownFlags('dispatch', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
366
300
  const jsonOut = process.argv.includes('--json');
367
301
  if (!jsonOut) console.log('[orchestrator] Scanning then dispatching...\n');
@@ -380,9 +314,6 @@ async function runDispatch() {
380
314
  console.log(`[${urgency}] ${item.skill_name}`);
381
315
  console.log(` Triggered by: ${item.triggered_by} (${item.finding_domain})`);
382
316
  console.log(` Action: ${item.action_required}`);
383
- // Surface per-CVE detail when the underlying finding had a list of
384
- // CVEs (e.g. cisa_kev_high_rwep). Operators need to know WHICH
385
- // CVE — not just an aggregate count.
386
317
  if (item.evidence && Array.isArray(item.evidence.items) && item.evidence.items.length > 0) {
387
318
  console.log(` Evidence:`);
388
319
  for (const ev of item.evidence.items) {
@@ -405,24 +336,18 @@ async function runDispatch() {
405
336
  }
406
337
 
407
338
  function runSkillContext(rawArgs) {
408
- // `rawArgs` is the full arg list. Filter --flags out of the positional set
409
- // before treating the first positional as the skill name — pre-fix
410
- // `skill --json` passed "--json" through as args[0] and reported
411
- // "Skill not found: --json".
339
+ // --flags are filtered out before the first positional is read as the skill
340
+ // name, or `skill --json` reports "Skill not found: --json".
412
341
  const argList = Array.isArray(rawArgs) ? rawArgs : (rawArgs == null ? [] : [rawArgs]);
413
- // Reject unknown flags with the shared structured envelope. skill consumes
414
- // only --json; the global air-gap flags are accepted-and-ignored (skill
415
- // context is read from local skill files, no egress).
342
+ // Air-gap flags are accepted and ignored; skill context is read from local files.
416
343
  if (rejectUnknownFlags('skill', argList, ['--json', '--air-gap', '--offline', '--no-network'])) return;
417
344
  const jsonOut = argList.includes('--json');
418
345
  const positionals = argList.filter(a => typeof a === 'string' && !a.startsWith('--'));
419
346
  const skillName = positionals[0];
420
347
 
421
348
  if (!skillName) {
422
- // Operators cannot guess the skill IDs (they are not the playbook names, and
423
- // `brief --all` lists playbooks, not skills). List the real skill IDs +
424
- // one-line descriptions straight from the signed manifest so `exceptd skill`
425
- // is self-documenting.
349
+ // Skill IDs are not the playbook names `brief --all` lists, so listing them
350
+ // from the signed manifest keeps `exceptd skill` self-documenting.
426
351
  let skills = [];
427
352
  try {
428
353
  skills = (require('../manifest.json').skills || [])
@@ -454,9 +379,8 @@ function runSkillContext(rawArgs) {
454
379
 
455
380
  const context = getSkillContext(skillName);
456
381
  if (!context) {
457
- // v0.13 envelope harmonization: ok:false bodies land on stdout
458
- // alongside successful results so a single consumer can parse the
459
- // verb's envelope without splitting across two streams.
382
+ // ok:false bodies land on stdout beside successful results, so one consumer
383
+ // parses the envelope without splitting across two streams.
460
384
  process.stdout.write(JSON.stringify({ ok: false, verb: "skill", error: `Skill not found: ${skillName}`, hint: "Run `exceptd skill` with no arguments to list all available skill IDs." }) + "\n");
461
385
  safeExit(EXIT_CODES.GENERIC_FAILURE);
462
386
  return;
@@ -491,8 +415,7 @@ function runPipeline(triggerType, payload) {
491
415
  }
492
416
 
493
417
  function runCurrency() {
494
- // --air-gap / --offline / --no-network: local-only verb, accepted-and-ignored
495
- // (see runScan).
418
+ // Local-only verb; the air-gap flags are accepted and ignored.
496
419
  if (rejectUnknownFlags('currency', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
497
420
  const jsonOut = process.argv.includes('--json');
498
421
  const result = runCurrencyNow();
@@ -520,22 +443,12 @@ function runCurrency() {
520
443
  }
521
444
 
522
445
  async function runReport(format) {
523
- // Reject unknown flags with the same structured envelope the other verbs
524
- // emit. report takes only a format positional; --json is accepted for
525
- // parity, and the global air-gap flags are honored downstream — the
526
- // report path's scan() TLS probe is suppressed under air-gap (scanner.js).
446
+ // The air-gap flags are honored downstream by scan()'s TLS probe.
527
447
  if (rejectUnknownFlags('report', args, ['--json', '--air-gap', '--offline', '--no-network'])) return;
528
- // v0.11.6 (#98): validate format positional. Pre-0.11.6 unknown formats
529
- // emitted a generic "# exceptd Report" header — silently accepted any
530
- // string. Now: reject with structured JSON error matching other verbs.
531
448
  const VALID_REPORT_FORMATS = ['executive', 'technical', 'compliance', 'csaf'];
532
449
  if (!VALID_REPORT_FORMATS.includes(format)) {
533
- // v0.13 envelope harmonization: ok:false body on stdout, exit 1
534
- // (GENERIC_FAILURE) not 2 (DETECTED_ESCALATE). Pre-v0.13 the
535
- // body went to stderr and exit was 2; both broke CI consumers
536
- // that expected the dispatch-error vs verb-finding distinction.
537
- // v0.13.2: did-you-mean on the unknown format value (reuses
538
- // lib/flag-suggest.js for Levenshtein-≤2 typo correction).
450
+ // GENERIC_FAILURE, not DETECTED_ESCALATE — exit 2 is how a CI consumer reads
451
+ // "the verb ran and found something".
539
452
  const { suggestFlag } = require('../lib/flag-suggest');
540
453
  const dym = suggestFlag(String(format), VALID_REPORT_FORMATS);
541
454
  const hint = dym ? ` Did you mean "${dym}"?` : '';
@@ -551,9 +464,7 @@ async function runReport(format) {
551
464
  return;
552
465
  }
553
466
 
554
- // v0.11.1 feature #55: `report csaf` emits a CSAF 2.0 envelope covering
555
- // every scanned finding + dispatched plan + currency posture. Useful for
556
- // VEX downstreams that ingest CSAF JSON.
467
+ // `report csaf` emits a CSAF 2.0 envelope for VEX downstreams.
557
468
  if (format === 'csaf') {
558
469
  const scanResult = await scan();
559
470
  const plan = dispatch(scanResult.findings);
@@ -573,28 +484,22 @@ async function runReport(format) {
573
484
  revision_history: [{ number: '1', date: new Date().toISOString(), summary: 'Initial report emission' }],
574
485
  },
575
486
  },
576
- // CSAF vulnerabilities[] are CVE-scoped by spec; non-CVE findings (signal
577
- // detections without a catalogued CVE) are preserved in
578
- // exceptd_extension.scan_summary below, not dropped.
487
+ // CSAF vulnerabilities[] is CVE-scoped by spec, so a signal detection
488
+ // without a catalogued CVE is preserved in exceptd_extension, not dropped.
579
489
  vulnerabilities: scanResult.findings
580
490
  .filter(f => f.cve_id)
581
491
  .map(f => {
582
492
  const vuln = {
583
493
  cve: f.cve_id,
584
- // Never emit a null description/threat detail — the CSAF schema
585
- // requires non-empty strings, so fall back through signal to a
586
- // generic label.
494
+ // The CSAF schema requires non-empty strings, so never emit null here.
587
495
  notes: [{ category: 'description', text: f.action_required || f.signal || 'Vulnerability detected' }],
588
496
  threats: f.severity === 'critical'
589
497
  ? [{ category: 'exploit_status', details: f.action_required || f.signal || 'Critical vulnerability detected' }]
590
498
  : [],
591
499
  };
592
- // Emit the REAL catalog CVSS, never a hardcoded base_score:0 (which
593
- // reads as "no impact" and inverts the risk for a critical CVE).
594
- // Mirror the playbook-runner CSAF emitter: a cvss_v3 block carries
595
- // vectorString (CSAF §3.2.1.5), so emit it only when both the score
596
- // and vector are known, and omit scores entirely otherwise rather
597
- // than fabricate one.
500
+ // The real catalog CVSS, never a hardcoded base_score of 0 — that reads
501
+ // as "no impact". A cvss_v3 block carries vectorString (CSAF §3.2.1.5),
502
+ // so emit only when both are known rather than fabricate one.
598
503
  if (typeof f.cvss_score === 'number' && Number.isFinite(f.cvss_score) &&
599
504
  typeof f.cvss_vector === 'string' && f.cvss_vector) {
600
505
  vuln.scores = [{ products: [], cvss_v3: { baseScore: f.cvss_score, vectorString: f.cvss_vector } }];
@@ -612,17 +517,13 @@ async function runReport(format) {
612
517
  return;
613
518
  }
614
519
 
615
- // Progress line goes to stderr so `report executive > out.md` produces
616
- // clean markdown on stdout — the first stdout line must be the report
617
- // header, not this progress notice.
520
+ // stderr, so `report executive > out.md` opens with the report header.
618
521
  console.error(`[orchestrator] Generating ${format} report...\n`);
619
522
  const scanResult = await scan();
620
523
  const plan = dispatch(scanResult.findings);
621
524
  const { currency_report } = currencyCheck();
622
525
 
623
- // The help advertises `--json for machine-readable output`. Previously only
624
- // `report csaf` emitted JSON; executive/technical/compliance silently
625
- // rendered Markdown even with --json. Honor the flag for those formats too.
526
+ // The help advertises --json for every format, not only csaf.
626
527
  if (args.includes('--json')) {
627
528
  process.stdout.write(JSON.stringify({
628
529
  ok: true,
@@ -639,9 +540,7 @@ async function runReport(format) {
639
540
  return;
640
541
  }
641
542
 
642
- // Bug #48: header now self-describes the report flavor so a piped-to-file
643
- // report carries its provenance internally. Previously only stderr
644
- // (`[orchestrator] Generating <X> report`) distinguished the three.
543
+ // The header self-describes the flavor, so a piped report carries its provenance.
645
544
  const flavorTitle = {
646
545
  executive: 'Executive Report',
647
546
  technical: 'Technical Report',
@@ -664,8 +563,6 @@ async function runReport(format) {
664
563
  }
665
564
 
666
565
  console.log('\n## Skill Currency');
667
- // Two tiers, named consistently and explained inline so the reader
668
- // doesn't have to mentally map two thresholds onto the same list.
669
566
  const critical = currency_report.filter(s => s.currency_score < 50);
670
567
  const stale = currency_report.filter(s => s.currency_score >= 50 && s.currency_score < 70);
671
568
  if (critical.length === 0 && stale.length === 0) {
@@ -683,15 +580,13 @@ async function runReport(format) {
683
580
  }
684
581
 
685
582
  /**
686
- * Resolve a writable directory for the watch lockfile. Prefer the operator's
687
- * home directory; fall back to the OS tempdir when home is non-writable or
688
- * non-existent (CI runners, restricted shells, etc.).
583
+ * A writable directory for the watch lockfile: the operator's home, falling
584
+ * back to the OS tempdir when home is absent or non-writable.
689
585
  */
690
586
  function _resolveWatchLockDir() {
691
587
  const home = process.env.EXCEPTD_HOME || pathMod.join(osMod.homedir(), '.exceptd');
692
588
  try {
693
589
  fsMod.mkdirSync(home, { recursive: true });
694
- // Probe writability with a small marker; remove on success.
695
590
  const probe = pathMod.join(home, `.write-probe-${process.pid}`);
696
591
  fsMod.writeFileSync(probe, '');
697
592
  fsMod.unlinkSync(probe);
@@ -704,16 +599,9 @@ function _resolveWatchLockDir() {
704
599
  }
705
600
 
706
601
  /**
707
- * Acquire an exclusive watch lockfile. Returns `{ path, release }` on
708
- * success. Throws with code 'EWATCHLOCKED' when another watcher holds the
709
- * lock and the lock looks fresh. Staleness is determined by two checks
710
- * combined: (a) the recorded PID is no longer alive, or (b) the file
711
- * mtime is older than 60s. Either path reclaims the lock atomically.
712
- *
713
- * The PID-alive check covers Windows, where SIGTERM cannot be delivered
714
- * to the prior watcher and our graceful release handler never ran (e.g.
715
- * the test harness kills with taskkill /F). Without it, every second
716
- * watch invocation would inherit a stale lock until the 60s mtime ages out.
602
+ * Returns `{ path, release }`, or throws code 'EWATCHLOCKED' when a live watcher
603
+ * holds the lock. Stale means the recorded PID is dead or the mtime is older than
604
+ * 60s; the PID check covers Windows, where the graceful release never ran.
717
605
  */
718
606
  function _acquireWatchLock() {
719
607
  const dir = _resolveWatchLockDir();
@@ -721,10 +609,8 @@ function _acquireWatchLock() {
721
609
  const STALE_MS = 60_000;
722
610
 
723
611
  function tryCreate() {
724
- // O_EXCL create with an explicit owner-only (0o600) mode: the predictable
725
- // lock-file name is the cross-process mutex, so safety is the exclusive
726
- // create + restrictive perms, not an unguessable name. The explicit mode
727
- // also clears the insecure-temporary-file static-analysis finding.
612
+ // The predictable lock-file name IS the mutex, so the safety is the exclusive
613
+ // create plus the owner-only mode, not an unguessable name.
728
614
  const fd = fsMod.openSync(lockPath, 'wx', 0o600);
729
615
  fsMod.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
730
616
  fsMod.closeSync(fd);
@@ -733,9 +619,7 @@ function _acquireWatchLock() {
733
619
  function _pidAlive(pid) {
734
620
  if (!Number.isInteger(pid) || pid <= 0) return false;
735
621
  try {
736
- // signal 0 is the POSIX "is the process alive" probe; Node implements
737
- // it on Windows too via OpenProcess. Throws ESRCH (or EPERM, which
738
- // also implies alive) when the PID is dead.
622
+ // Signal 0 is the "is it alive" probe; ESRCH means dead, EPERM alive.
739
623
  process.kill(pid, 0);
740
624
  return true;
741
625
  } catch (e) {
@@ -766,11 +650,8 @@ function _acquireWatchLock() {
766
650
  e.code = 'EWATCHLOCKED';
767
651
  throw e;
768
652
  }
769
- // Stale — reclaim atomically. unlink + re-create with O_EXCL. If another
770
- // reclaimer won the race between our unlink and our re-create, the O_EXCL
771
- // create throws EEXIST; that is contention, not a generic failure, so
772
- // re-tag it as EWATCHLOCKED to map onto the EX_TEMPFAIL "retry later"
773
- // exit rather than the GENERIC_FAILURE the bare EEXIST would fall to.
653
+ // Stale — unlink and re-create with O_EXCL. Losing that race is contention,
654
+ // so it is re-tagged EWATCHLOCKED to reach the EX_TEMPFAIL "retry later" exit.
774
655
  try { fsMod.unlinkSync(lockPath); } catch { /* concurrent reclaim, fine */ }
775
656
  try {
776
657
  tryCreate();
@@ -795,27 +676,20 @@ function _acquireWatchLock() {
795
676
  }
796
677
 
797
678
  async function runWatch() {
798
- // Reject unknown flags before acquiring the watch lock / starting the
799
- // scheduler. --log-file is the value-taking option this verb consumes;
800
- // --json is accepted for parity; the global air-gap flags are
801
- // accepted-and-ignored (watch does no egress of its own).
679
+ // Reject unknown flags before acquiring the lock or starting the scheduler.
680
+ // --log-file is the value-taking option; watch does no egress of its own.
802
681
  if (rejectUnknownFlags('watch', args, ['--log-file', '--json', '--air-gap', '--offline', '--no-network'])) return;
803
682
  const { flags, options } = parseFlags(args, ['--log-file']);
804
683
  const logFilePath = options.get('--log-file');
805
684
  let logStream = null;
806
- // Tee stdout when --log-file is set. We intercept process.stdout.write so
807
- // every console.log + raw write also lands in the log file; this is the
808
- // simplest pattern that captures scheduler + event-bus + this verb's
809
- // output uniformly without rewriting every console.log call site.
685
+ // Intercepting process.stdout.write tees the scheduler and event bus too.
810
686
  if (logFilePath) {
811
687
  try {
812
688
  fsMod.mkdirSync(pathMod.dirname(pathMod.resolve(logFilePath)), { recursive: true });
813
689
  logStream = fsMod.createWriteStream(pathMod.resolve(logFilePath), { flags: 'a' });
814
690
  } catch (err) {
815
- // A filesystem error opening the log target (ENOENT/EACCES/EROFS) is a
816
- // generic operational failure, not a detected-finding escalation — code
817
- // 2 is DETECTED_ESCALATE and would misroute a CI gate. Use the same
818
- // GENERIC_FAILURE every other error site in this function uses.
691
+ // A filesystem error opening the log target is a generic failure, not a
692
+ // detected finding — exit 2 would misroute a CI gate.
819
693
  console.error(`[orchestrator] --log-file ${logFilePath}: ${err.message}`);
820
694
  safeExit(EXIT_CODES.GENERIC_FAILURE);
821
695
  return;
@@ -826,21 +700,18 @@ async function runWatch() {
826
700
  return origWrite(chunk, enc, cb);
827
701
  };
828
702
  }
829
- // Touch flags to keep eslint-style linters quiet about unused locals.
703
+ // Parsed for symmetry with the other verbs, unused here.
830
704
  void flags;
831
705
 
832
- // Acquire the cross-process watch lock before any heavy work. The lock
833
- // prevents two concurrent watchers from emitting double events or
834
- // double-firing scheduler bootstraps. Release happens in every shutdown
835
- // path (SIGINT/SIGTERM/SIGHUP/SIGBREAK).
706
+ // The cross-process lock stops two watchers double-emitting events and
707
+ // double-firing the scheduler bootstrap. Every shutdown path releases it.
836
708
  let lock;
837
709
  try {
838
710
  lock = _acquireWatchLock();
839
711
  } catch (err) {
840
712
  console.error(`[orchestrator] cannot start watch: ${err.message}`);
841
- // sysexits EX_TEMPFAIL: the watch daemon lock is held; retry later. Read
842
- // the value from the canonical exit-code table when it carries it so the
843
- // `doctor --exit-codes` dump and this path share one source of truth.
713
+ // sysexits EX_TEMPFAIL, read from the canonical table so this path and the
714
+ // `doctor --exit-codes` dump agree.
844
715
  const watchLocked = EXIT_CODES.WATCH_LOCK_CONTENTION || 75;
845
716
  process.exitCode = err.code === 'EWATCHLOCKED' ? watchLocked : 1;
846
717
  return;
@@ -850,9 +721,8 @@ async function runWatch() {
850
721
  console.log(`[orchestrator] Lockfile: ${lock.path}`);
851
722
  console.log('Listening for: CISA KEV additions, ATLAS updates, CVE drops, framework amendments.\n');
852
723
 
853
- // Save the listener reference so the shutdown path can detach it instead
854
- // of leaving it bound (a leaked '*' listener accumulates across
855
- // start/stop cycles in long-running embedders).
724
+ // Held so shutdown can detach it — a leaked '*' listener accumulates across
725
+ // start/stop cycles.
856
726
  const anyListener = (event) => {
857
727
  console.log(`[event] ${event.type} — ${event.timestamp}`);
858
728
  if (event.affected_skills.length > 0) {
@@ -877,18 +747,13 @@ async function runWatch() {
877
747
  if (logStream) {
878
748
  try { logStream.end(); } catch { /* best-effort */ }
879
749
  }
880
- // process.exitCode + return-from-handler pattern: let the loop drain
881
- // (so stdout flushes the goodbye line + the log stream finishes) rather
882
- // than a hard process.exit() that can truncate.
750
+ // process.exitCode, not process.exit() — the loop drains so the goodbye line
751
+ // flushes and the log stream finishes.
883
752
  process.exitCode = 0;
884
753
  };
885
754
 
886
- // Standard signal coverage. SIGINT (Ctrl+C) was the only one previously
887
- // handled; SIGTERM is the conventional "graceful stop" signal sent by
888
- // process managers (systemd, docker stop, kubernetes), and SIGHUP is the
889
- // terminal-close signal on POSIX. SIGBREAK fires on Ctrl+Break on Windows.
890
- // SIGHUP doesn't exist on Windows and registering it there is a no-op
891
- // that throws on some Node versions; gate by platform.
755
+ // SIGHUP does not exist on Windows and registering it throws on some Node
756
+ // versions, so the platform gate picks SIGBREAK instead.
892
757
  process.on('SIGINT', () => shutdown('SIGINT'));
893
758
  process.on('SIGTERM', () => shutdown('SIGTERM'));
894
759
  if (process.platform !== 'win32') {
@@ -900,24 +765,19 @@ async function runWatch() {
900
765
  console.log('Press Ctrl+C to stop. (SIGTERM / SIGHUP / SIGBREAK also honored.)\n');
901
766
  }
902
767
 
903
- // Known flags accepted by validate-cves. An unknown flag (typo) must be
904
- // rejected before any network work — a swallowed `--ofline` previously fell
905
- // through to the default live-network path and the verb hung fetching the
906
- // whole catalog from NVD until killed.
768
+ // An unknown flag must be rejected BEFORE any network work: a swallowed
769
+ // `--ofline` falls through to the live path and fetches the whole NVD catalog.
907
770
  const VALIDATE_CVES_KNOWN_FLAGS = Object.freeze([
908
771
  '--offline', '--no-fail', '--from-cache', '--concurrency', '--since', '--air-gap',
909
772
  ]);
910
- // Known flags accepted by validate-rfcs (same hang-on-typo class as
911
- // validate-cves). `--live` is the explicit opt-in to the default network
912
- // path; `--air-gap` forces the offline view with no egress.
773
+ // `--live` is the explicit opt-in to the default network path; `--air-gap`
774
+ // forces the offline view.
913
775
  const VALIDATE_RFCS_KNOWN_FLAGS = Object.freeze([
914
776
  '--offline', '--no-fail', '--from-cache', '--since', '--live', '--air-gap',
915
777
  ]);
916
778
 
917
- // Reject unknown --flags against a per-verb allowlist BEFORE any network work.
918
- // Returns true when a rejection was emitted (caller should return); false when
919
- // every flag is recognized. The base flag (text before any `=`) is what gets
920
- // matched so `--from-cache=path` and `--since=2026-01-01` are accepted.
779
+ // True when a rejection was emitted and the caller must return. Matching is on
780
+ // the base flag — the text before any `=` — so `--since=2026-01-01` is accepted.
921
781
  function rejectUnknownFlags(verb, rawArgs, knownFlags) {
922
782
  const known = new Set(knownFlags);
923
783
  const unknown = rawArgs
@@ -925,7 +785,6 @@ function rejectUnknownFlags(verb, rawArgs, knownFlags) {
925
785
  .map(a => { const eq = a.indexOf('='); return eq === -1 ? a : a.slice(0, eq); })
926
786
  .filter(base => !known.has(base));
927
787
  if (unknown.length === 0) return false;
928
- // Dedupe while preserving order.
929
788
  const uniq = [...new Set(unknown)];
930
789
  process.stdout.write(JSON.stringify({
931
790
  ok: false,
@@ -947,15 +806,9 @@ async function runValidateCves(rawArgs = []) {
947
806
  const flags = new Set(rawArgs.filter(a => a.startsWith('--')));
948
807
  const offline = flags.has('--offline') || flags.has('--air-gap');
949
808
  const noFail = flags.has('--no-fail');
950
- // --from-cache: prefer cached upstream snapshots before falling back to live
951
- // network. Accepts an optional path; defaults to .cache/upstream when bare.
952
- // The cache layout is fixed by lib/prefetch.js — same one refresh-external
953
- // reads from.
809
+ // --from-cache takes an optional path; the layout is fixed by lib/prefetch.js.
954
810
  let cacheDir = null;
955
- // --concurrency N — bound the number of in-flight upstream calls during
956
- // live validation. Default stays 4 to match the prior implicit value;
957
- // operators with faster networks can crank it up, air-gapped fleets can
958
- // drop it to 1 to keep cache reads serial.
811
+ // --concurrency bounds in-flight upstream calls during live validation.
959
812
  let concurrency = 4;
960
813
  for (let i = 0; i < rawArgs.length; i++) {
961
814
  const a = rawArgs[i];
@@ -985,9 +838,7 @@ async function runValidateCves(rawArgs = []) {
985
838
  return;
986
839
  }
987
840
 
988
- // --since <ISO|YYYY-MM-DD>: scope-limit validation to CVEs whose
989
- // last_updated (or cisa_kev_date when missing) is on or after the given
990
- // date. Cuts upstream calls for fleet operators running cron jobs.
841
+ // --since scopes to CVEs whose last_updated (or cisa_kev_date) is on or after it.
991
842
  let sinceDate = null;
992
843
  for (let i = 0; i < rawArgs.length; i++) {
993
844
  if (rawArgs[i] === '--since' && rawArgs[i + 1]) sinceDate = rawArgs[i + 1];
@@ -1011,11 +862,8 @@ async function runValidateCves(rawArgs = []) {
1011
862
  const modeStr = offline
1012
863
  ? 'offline (local view only)'
1013
864
  : (cacheDir ? `live with cache (${path.relative(path.join(__dirname, '..'), cacheDir)})` : 'live (NVD + CISA KEV)');
1014
- // v0.13.6: clarify that this count is CVE-* IDs validated against NVD,
1015
- // not the full catalog. MAL-* (malicious package) entries are listed in
1016
- // data/cve-catalog.json but are out of scope for NVD validation — they
1017
- // do not have CVE IDs by design. `exceptd doctor` reports the combined
1018
- // catalog total separately to avoid the "where did MAL-* go?" misread.
865
+ // CVE-* IDs validated against NVD, not the whole catalog: MAL-* entries live in
866
+ // the same file but carry no CVE ID by design.
1019
867
  const allKeys = Object.keys(catalog).filter((k) => !k.startsWith('_'));
1020
868
  const nonCveCount = allKeys.length - cveIds.length;
1021
869
  const totalNote = nonCveCount > 0
@@ -1024,7 +872,6 @@ async function runValidateCves(rawArgs = []) {
1024
872
  console.log(`${cveIds.length} CVE IDs queued for NVD validation${totalNote}. Mode: ${modeStr}${sinceDate ? ` · since=${sinceDate}` : ''}`);
1025
873
  console.log(`Fail-on-drift: ${noFail ? 'disabled' : 'enabled'}\n`);
1026
874
 
1027
- // --- Header (fixed-width; works with the existing currency command's style)
1028
875
  const header = 'CVE | Local RWEP | Local CVSS | NVD CVSS | KEV Local | KEV NVD | EPSS Local | EPSS Live | EPSS Drift | Status';
1029
876
  const rule = '-------------------|------------|------------|------------------|-----------|---------|-----------------|-----------------|------------|----------';
1030
877
  console.log(header);
@@ -1035,7 +882,6 @@ async function runValidateCves(rawArgs = []) {
1035
882
  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
1036
883
  }
1037
884
 
1038
- // Format an EPSS pair as "score / percentile" with 4-decimal score, 2-decimal pct.
1039
885
  function fmtEpss(score, pct) {
1040
886
  if (score === null || score === undefined) return '-';
1041
887
  const s = Number(score).toFixed(4);
@@ -1064,15 +910,8 @@ async function runValidateCves(rawArgs = []) {
1064
910
  return;
1065
911
  }
1066
912
 
1067
- // Live path — opportunistically use the prefetch cache when --from-cache
1068
- // is set. Cache-resolved CVEs short-circuit the network fetch; missing
1069
- // entries fall through to the live validator. Both paths produce the
1070
- // same ValidationResult shape.
1071
- //
1072
- // Graceful fallback when sources/validators isn't shipped (matches the
1073
- // pattern validate-rfcs uses below). Pre-v0.10.3 this crashed with
1074
- // MODULE_NOT_FOUND in installed npm packages because sources/ wasn't
1075
- // in the files allowlist.
913
+ // sources/validators is absent from some installs, hence the guarded require
914
+ // and the offline fallback.
1076
915
  let validateAllCves;
1077
916
  try {
1078
917
  ({ validateAllCves } = require('../sources/validators'));
@@ -1092,7 +931,6 @@ async function runValidateCves(rawArgs = []) {
1092
931
  report = await validateAllCves(catalog, { concurrency });
1093
932
  }
1094
933
 
1095
- // Index results by cve_id (validateAllCves preserves insertion order, but be explicit).
1096
934
  const byId = new Map(report.results.map(r => [r.cve_id, r]));
1097
935
  let driftFound = 0;
1098
936
  let unreachable = 0;
@@ -1111,7 +949,6 @@ async function runValidateCves(rawArgs = []) {
1111
949
  const cvssMismatch = r?.discrepancies?.some(d => d.field === 'cvss_score');
1112
950
  const kevMismatch = r?.discrepancies?.some(d => d.field === 'cisa_kev');
1113
951
 
1114
- // EPSS Local / Live / Drift block
1115
952
  const liveEpss = r?.fetched?.epss || null;
1116
953
  const epssReachable = r?.fetched?.sources?.epss?.reachable === true;
1117
954
  const epssMismatchScore = r?.discrepancies?.some(d => d.field === 'epss_score');
@@ -1169,23 +1006,10 @@ async function runValidateCves(rawArgs = []) {
1169
1006
  }
1170
1007
 
1171
1008
  /**
1172
- * validate-rfcs — companion to validate-cves for the IETF RFC / Internet-Draft
1173
- * catalog. Confirms that every entry in data/rfc-references.json is current
1174
- * against the IETF Datatracker.
1175
- *
1176
- * Modes:
1177
- * --offline Print the local view only; do not fetch. Useful for airgapped
1178
- * CI runs and for fast iteration on the catalog file itself.
1179
- * --live Fetch the IETF Datatracker for each RFC / draft (default if
1180
- * neither flag passed).
1181
- * --no-fail Report drift but exit zero. Useful when you want a quarterly
1182
- * drift report without blocking CI.
1183
- *
1184
- * Per AGENTS.md hard rule #12 (external data version pinning), drift surfaces
1185
- * are: status change (Draft → Standards Track → Internet Standard), new
1186
- * errata since `last_verified`, replaced-by relationships, and obsoletion.
1187
- * A local entry with no upstream is flagged. Network errors return
1188
- * `unreachable` for that entry — they never fail the run.
1009
+ * Confirms every entry in data/rfc-references.json is current against the IETF
1010
+ * Datatracker. Drift is a status change, new errata since `last_verified`, a
1011
+ * replaced-by relationship, or obsoletion; a network error returns `unreachable`
1012
+ * for that entry and never fails the run.
1189
1013
  */
1190
1014
  async function runValidateRfcs(rawArgs = []) {
1191
1015
  const fs = require('fs');
@@ -1254,8 +1078,7 @@ async function runValidateRfcs(rawArgs = []) {
1254
1078
  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length);
1255
1079
  }
1256
1080
 
1257
- // Lazy-load the validator so an environment without `sources/validators`
1258
- // installed still gets the offline view.
1081
+ // Lazy-loaded so an install without sources/validators still gets the offline view.
1259
1082
  let validator = null;
1260
1083
  if (!offline) {
1261
1084
  try {
@@ -1268,9 +1091,8 @@ async function runValidateRfcs(rawArgs = []) {
1268
1091
  let driftFound = 0;
1269
1092
  let unreachable = 0;
1270
1093
 
1271
- // Cache-first helpers — read the prefetch payload for an RFC/draft and
1272
- // compute drift the same way validateRfc would. Cache misses fall through
1273
- // to the live validator.
1094
+ // Cache-first: drift is computed the way validateRfc would, and a miss falls
1095
+ // through to the validator.
1274
1096
  const STATUS_MAP = {
1275
1097
  std: 'Internet Standard', ps: 'Proposed Standard', ds: 'Draft Standard',
1276
1098
  bcp: 'Best Current Practice', inf: 'Informational', exp: 'Experimental',
@@ -1356,19 +1178,12 @@ async function runValidateRfcs(rawArgs = []) {
1356
1178
  }
1357
1179
 
1358
1180
  /**
1359
- * watchlist — aggregate `forward_watch` entries across every skill in
1360
- * manifest.json into a single deduplicated, sorted list, with the skills
1361
- * that listed each item and the most recent `last_threat_review` date among
1362
- * them. Supports `--by-skill` to invert the view (per-skill watch items).
1363
- *
1364
- * Per AGENTS.md, `forward_watch` is the optional frontmatter field every
1365
- * skill uses to flag upcoming standards changes, new TTPs, or RFC drops
1366
- * that should trigger a skill update. This command surfaces the union so
1367
- * maintainers can see the full horizon at a glance.
1181
+ * Aggregates the `forward_watch` frontmatter across every manifest skill into
1182
+ * one deduplicated, sorted list, carrying the skills that listed each item and
1183
+ * the most recent `last_threat_review` among them. --by-skill inverts the view.
1368
1184
  */
1369
- // Every flag any watchlist mode (default aggregator, --alerts, --org-scan)
1370
- // accepts. The unknown-flag guard runs once at the top of runWatchlist so a
1371
- // typo is rejected regardless of which sub-mode it would have reached.
1185
+ // Every flag any watchlist mode accepts; the guard runs once at the top of
1186
+ // runWatchlist, whichever sub-mode a typo would have reached.
1372
1187
  const WATCHLIST_KNOWN_FLAGS = Object.freeze([
1373
1188
  '--json', '--by-skill', '--alerts', '--org-scan',
1374
1189
  '--org', '--pattern', '--output-format', '--air-gap',
@@ -1387,22 +1202,11 @@ function runWatchlist(rawArgs = []) {
1387
1202
  const manifestPath = path.join(__dirname, '..', 'manifest.json');
1388
1203
  const repoRoot = path.join(__dirname, '..');
1389
1204
 
1390
- // v0.13.1: --alerts re-scopes watchlist from "skills forward_watch" to
1391
- // "CVE-class alert patterns" — surfaces catalog entries matching
1392
- // high-priority shape rules (kernel-LPE-with-PoC, supply-chain-family,
1393
- // AI-discovered-KEV, recently-disclosed-with-active-exploitation).
1394
1205
  // The modes are mutually exclusive; the first matching flag wins.
1395
1206
  if (alertsMode) {
1396
1207
  return runWatchlistAlerts(rawArgs);
1397
1208
  }
1398
1209
 
1399
- // v0.13.3: --org-scan probes GitHub for repository naming patterns
1400
- // associated with known threat actors (per NEW-CTRL-052 from the
1401
- // MAL-2026-SHAI-HULUD-OSS zeroday-lessons entry). The Shai-Hulud
1402
- // worm uses GitHub itself as the exfil channel — repos named
1403
- // "A Gift From TeamPCP", "Shai-Hulud-*", or with future variants
1404
- // hold the stolen credentials. Operators can run this against
1405
- // their own GitHub org to surface exfil staging in their tenant.
1406
1210
  if (orgScanMode) {
1407
1211
  return runWatchlistOrgScan(rawArgs);
1408
1212
  }
@@ -1416,10 +1220,7 @@ function runWatchlist(rawArgs = []) {
1416
1220
  return;
1417
1221
  }
1418
1222
 
1419
- // Exclude entries that are explicitly marked `status: "deprecated"` so
1420
- // operator forward-watch decisions aren't anchored to skills that are
1421
- // about to come out of the manifest. The field is optional today; when
1422
- // absent every skill is treated as active.
1223
+ // Deprecated skills are excluded; the field is optional, absent means active.
1423
1224
  const skills = (Array.isArray(manifest.skills) ? manifest.skills : [])
1424
1225
  .filter(s => s && s.status !== 'deprecated');
1425
1226
  // item -> { skills: [{name, last_threat_review}] }
@@ -1460,10 +1261,7 @@ function runWatchlist(rawArgs = []) {
1460
1261
 
1461
1262
  const jsonOut = rawArgs.includes('--json');
1462
1263
  if (jsonOut) {
1463
- // v0.13.0 envelope harmonization: top-level `ok: true` so every
1464
- // verb's JSON body shares the contract whether emitted by
1465
- // bin/exceptd.js emit() (which auto-defaults `ok`) or by the
1466
- // orchestrator dispatch (which writes stdout directly).
1264
+ // Top-level ok:true so every verb's JSON body shares one contract.
1467
1265
  const out = {
1468
1266
  ok: true,
1469
1267
  generated_at: new Date().toISOString(),
@@ -1520,30 +1318,9 @@ function runWatchlist(rawArgs = []) {
1520
1318
  }
1521
1319
 
1522
1320
  /**
1523
- * v0.13.1 — runWatchlistAlerts surfaces CVE catalog entries matching
1524
- * high-priority pattern rules. Closes the post-mortem gap from
1525
- * CVE-2026-46333 (ssh-keysign-pwn) where the toolkit shipped a CVE
1526
- * matching the kernel-LPE-with-public-PoC shape but had no programmatic
1527
- * way for an operator to ask "what just landed that needs attention?".
1528
- *
1529
- * Patterns are evaluated against every catalog entry; multiple patterns
1530
- * may fire on the same entry (the report carries the list). The age
1531
- * filter — pattern.fresh_days — bounds the "recently disclosed" patterns;
1532
- * older entries already had attention.
1533
- *
1534
- * Output (JSON mode):
1535
- * {
1536
- * ok: true,
1537
- * verb: "watchlist",
1538
- * mode: "alerts",
1539
- * generated_at: "...",
1540
- * patterns_evaluated: 5,
1541
- * entries_scanned: 39,
1542
- * alerts: [
1543
- * { cve_id, name, rwep_score, patterns: ["kernel_lpe_class", ...],
1544
- * disclosed: "...", source_verified: "...", links: [...] }
1545
- * ]
1546
- * }
1321
+ * Surfaces CVE catalog entries matching high-priority pattern rules. Every entry
1322
+ * is evaluated against every pattern and several may fire on one entry, so the
1323
+ * report carries the list rather than a single label.
1547
1324
  */
1548
1325
  function runWatchlistAlerts(rawArgs = []) {
1549
1326
  const fs = require('fs');
@@ -1559,9 +1336,8 @@ function runWatchlistAlerts(rawArgs = []) {
1559
1336
  return;
1560
1337
  }
1561
1338
 
1562
- // Pattern definitions. Each pattern is a predicate against a single
1563
- // catalog entry plus a label + severity. Patterns are intentionally
1564
- // narrow — false-positive flood would defeat the alert purpose.
1339
+ // A pattern is a predicate over one entry plus a label and severity. Kept
1340
+ // narrow — a false-positive flood defeats the alert.
1565
1341
  const today = new Date();
1566
1342
  function daysSince(iso) {
1567
1343
  if (typeof iso !== 'string') return Infinity;
@@ -1639,8 +1415,7 @@ function runWatchlistAlerts(rawArgs = []) {
1639
1415
  });
1640
1416
  }
1641
1417
 
1642
- // Sort: critical-severity matches first, then high, then medium; within
1643
- // each band, highest RWEP first; finally CVE-id ascending for stability.
1418
+ // Severity band first, then highest RWEP, then CVE id for a stable order.
1644
1419
  const severityWeight = { critical: 0, high: 1, medium: 2, low: 3 };
1645
1420
  alerts.sort((a, b) => {
1646
1421
  const sa = Math.min(...a.patterns.map((p) => severityWeight[p.severity] ?? 9));
@@ -1684,14 +1459,10 @@ function runWatchlistAlerts(rawArgs = []) {
1684
1459
  }
1685
1460
 
1686
1461
  /**
1687
- * Cache-first variant of validateAllCves. For each catalog CVE, reads the
1688
- * NVD + EPSS payload from the prefetch cache (cacheDir/nvd/<id>.json +
1689
- * cacheDir/epss/<id>.json) and the KEV feed from cacheDir/kev/. Builds a
1690
- * ValidationResult matching the shape sources/validators/cve-validator.js
1691
- * produces so downstream consumers don't have to fork their logic.
1692
- *
1693
- * Missing cache entries fall through to the live validator for that CVE,
1694
- * so partial caches still produce a complete report.
1462
+ * Cache-first variant of validateAllCves: builds a ValidationResult matching the
1463
+ * shape sources/validators/cve-validator.js produces, so a downstream consumer
1464
+ * needs no second code path. A missing cache entry falls through to the live
1465
+ * validator, so a partial cache still yields a complete report.
1695
1466
  */
1696
1467
  async function validateAllCvesPreferCache(catalog, cacheDir) {
1697
1468
  const fs = require('fs');
@@ -1699,27 +1470,20 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
1699
1470
  const crypto = require('crypto');
1700
1471
  const { validateCve } = require('../sources/validators');
1701
1472
 
1702
- // 50 MB cap on any single cache file. Refuses to read past the cap to
1703
- // protect against a tampered or malformed prefetch payload that would
1704
- // OOM the validator on JSON.parse. The cap is well above any legitimate
1705
- // NVD / EPSS / KEV file (largest in practice is the full KEV feed at
1706
- // ~3 MB as of 2026); anything beyond 50 MB is almost certainly damage.
1473
+ // Cap on any single cache file: a malformed prefetch payload would otherwise
1474
+ // OOM the validator on JSON.parse. Well above the few-MB full KEV feed.
1707
1475
  const CACHE_FILE_MAX_BYTES = 50 * 1024 * 1024;
1708
1476
 
1709
- // Load the prefetch cache index once. Every payload written by the prefetch
1710
- // pipeline records a per-entry sha256 here, so a consume-side recompute
1711
- // catches a payload rewritten on disk after prefetch. The fingerprint is
1712
- // computed over JSON.stringify(payload) (unindented) at write time, so the
1713
- // round-trip parse + re-stringify below produces the comparable hash.
1477
+ // Every prefetch payload records a per-entry sha256, so recomputing on read
1478
+ // catches a rewrite. It is taken over JSON.stringify(payload) unindented,
1479
+ // which is why the re-stringify below matches.
1714
1480
  let cacheIndex = null;
1715
1481
  try {
1716
1482
  cacheIndex = JSON.parse(fs.readFileSync(path.join(cacheDir, '_index.json'), 'utf8'));
1717
1483
  } catch { cacheIndex = null; }
1718
1484
 
1719
- // Verify a cache payload against its recorded sha256 before it is trusted.
1720
- // Returns false on any tamper signal: no index, no sha256 entry, or a
1721
- // mismatch. The caller then refuses the cached value and re-validates the
1722
- // CVE live, so forged cache contents can no longer mask drift.
1485
+ // False on any tamper signal — no index, no sha256 entry, or a mismatch. The
1486
+ // caller then validates that CVE live, so forged cache cannot mask drift.
1723
1487
  function cacheEntryVerified(source, id, parsed) {
1724
1488
  const meta = cacheIndex && cacheIndex.entries && cacheIndex.entries[`${source}/${id}`];
1725
1489
  if (!meta || typeof meta.sha256 !== 'string') {
@@ -1747,10 +1511,8 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
1747
1511
  console.error(`[validate-cves] cache file ${p} exceeds ${CACHE_FILE_MAX_BYTES} byte cap (${st.size}); refusing to read.`);
1748
1512
  return null;
1749
1513
  }
1750
- // readFileSync(fd) loops read() to EOF — a single readSync may return
1751
- // fewer than st.size bytes on network/FUSE/sync-backed fds, leaving the
1752
- // tail NUL-filled and truncating the JSON. Reading via the open fd keeps
1753
- // the open→fstat ordering TOCTOU-free.
1514
+ // readFileSync(fd) loops to EOF — a single readSync can come back short on
1515
+ // a network fd, NUL-filling the tail and truncating the JSON.
1754
1516
  const parsed = JSON.parse(fs.readFileSync(fd, 'utf8'));
1755
1517
  if (!cacheEntryVerified(source, id, parsed)) return null;
1756
1518
  return parsed;
@@ -1803,7 +1565,6 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
1803
1565
  const epssPayload = readCached('epss', id);
1804
1566
 
1805
1567
  if (!nvdPayload && !kevFeed && !epssPayload) {
1806
- // No cache for this CVE on any source — fall through to live.
1807
1568
  liveFallbacks++;
1808
1569
  try {
1809
1570
  const r = await validateCve(id, local);
@@ -1930,30 +1691,14 @@ Examples:
1930
1691
  }
1931
1692
 
1932
1693
  /**
1933
- * v0.13.3 — runWatchlistOrgScan: GitHub repo-pattern monitoring per
1934
- * NEW-CTRL-052 (MAL-2026-SHAI-HULUD-OSS zeroday-lessons). The Shai-Hulud
1935
- * worm uses GitHub itself as the exfil channel; the canonical naming
1936
- * pattern is "A Gift From TeamPCP", commit-timestamps falsified to
1937
- * 2099-01-01, and contributor accounts agwagwagwa / headdirt / tmechen.
1938
- *
1939
- * Operators run this against their GitHub org (--org <login> or
1940
- * GITHUB_ORG env var) to surface exfil staging within their tenant.
1941
- * Uses the GitHub Search API (unauthenticated for public-repo search;
1942
- * GITHUB_TOKEN env var lifts the rate limit + enables private-repo
1943
- * coverage). Report-only — never modifies repos.
1944
- *
1945
- * Flags / env:
1946
- * --org <login> GitHub org / user to scope the search to (required)
1947
- * --pattern <s> (repeatable) additional naming-pattern strings (defaults below)
1948
- * --json structured JSON output
1949
- * GITHUB_TOKEN env var lifts rate limit + private-repo search coverage
1694
+ * GitHub repo-pattern monitoring per NEW-CTRL-052: the Shai-Hulud worm uses
1695
+ * GitHub itself as the exfil channel, so an operator scans their own org
1696
+ * (--org <login>, or GITHUB_ORG) for the threat-actor naming patterns.
1697
+ * GITHUB_TOKEN lifts the rate limit and adds private-repo coverage.
1950
1698
  */
1951
1699
  async function runWatchlistOrgScan(rawArgs = []) {
1952
1700
  const jsonOut = rawArgs.includes('--json');
1953
- // v0.13.5: --output-format markdown emits a GitHub-flavored markdown
1954
- // table suitable for pasting into a PR / issue body for ops follow-up.
1955
- // Accepted values: json | markdown | human (default). --json is the
1956
- // legacy short form and remains accepted (treated as --output-format json).
1701
+ // --output-format takes json | markdown | human; --json resolves to json.
1957
1702
  let outputFormat = jsonOut ? 'json' : 'human';
1958
1703
  for (let i = 0; i < rawArgs.length; i++) {
1959
1704
  if (rawArgs[i] === '--output-format' && rawArgs[i + 1]) outputFormat = rawArgs[i + 1];
@@ -1972,7 +1717,6 @@ async function runWatchlistOrgScan(rawArgs = []) {
1972
1717
  safeExit(EXIT_CODES.GENERIC_FAILURE);
1973
1718
  return;
1974
1719
  }
1975
- // Extract --org <login>. Accept --org=foo too.
1976
1720
  let org = null;
1977
1721
  for (let i = 0; i < rawArgs.length; i++) {
1978
1722
  if (rawArgs[i] === '--org' && rawArgs[i + 1]) { org = rawArgs[i + 1]; break; }
@@ -1989,10 +1733,8 @@ async function runWatchlistOrgScan(rawArgs = []) {
1989
1733
  safeExit(EXIT_CODES.GENERIC_FAILURE);
1990
1734
  return;
1991
1735
  }
1992
- // Air-gap guard. The org-scan reaches api.github.com on every pattern
1993
- // query; under air-gap there is no offline substitute, so refuse before
1994
- // any fetch rather than silently attempting egress. Mirrors the
1995
- // source-ghsa air-gap refusal shape (ok:false + source + explicit error).
1736
+ // Every pattern query reaches api.github.com with no offline substitute, so
1737
+ // air-gap refuses before any fetch. Same refusal shape as source-ghsa.
1996
1738
  if (process.env.EXCEPTD_AIR_GAP === '1' || rawArgs.includes('--air-gap')) {
1997
1739
  process.stdout.write(JSON.stringify({
1998
1740
  ok: false,
@@ -2004,8 +1746,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
2004
1746
  safeExit(EXIT_CODES.BLOCKED);
2005
1747
  return;
2006
1748
  }
2007
- // Custom patterns via --pattern <s> (repeatable). Default set from
2008
- // the MAL-2026-SHAI-HULUD-OSS catalog entry + NEW-CTRL-052 evidence.
1749
+ // --pattern is repeatable; the defaults come from MAL-2026-SHAI-HULUD-OSS.
2009
1750
  const customPatterns = [];
2010
1751
  for (let i = 0; i < rawArgs.length; i++) {
2011
1752
  if (rawArgs[i] === '--pattern' && rawArgs[i + 1]) customPatterns.push(rawArgs[i + 1]);
@@ -2021,9 +1762,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
2021
1762
  ...customPatterns.map((q, i) => ({ id: `custom-${i + 1}`, q, severity: 'medium', source: 'operator --pattern' })),
2022
1763
  ];
2023
1764
 
2024
- // GitHub Search API: GET /search/code?q=<query>+org:<login>
2025
- // and GET /search/repositories?q=<query>+org:<login>. Both return
2026
- // the same shape (items[] with name, html_url, owner, created_at).
1765
+ // Both GitHub search endpoints return items[] with name, html_url, owner, created_at.
2027
1766
  const token = process.env.GITHUB_TOKEN || '';
2028
1767
  const matches = [];
2029
1768
  let rateLimited = false;
@@ -2038,19 +1777,15 @@ async function runWatchlistOrgScan(rawArgs = []) {
2038
1777
  safeExit(EXIT_CODES.GENERIC_FAILURE);
2039
1778
  return;
2040
1779
  }
2041
- // Patterns whose query exhausted retries on a transient failure. Surfaced
2042
- // in the envelope so a dropped pattern is observable rather than reading as
2043
- // a clean zero — the false-negative-on-transient class (a flaky 5xx /
2044
- // reset / timeout silently yielding "no matches found" for a threat-actor
2045
- // naming pattern) is exactly what NEW-CTRL-052 exists to defend against.
1780
+ // Patterns whose query exhausted its retries, surfaced in the envelope: a flaky
1781
+ // 5xx silently yielding "no matches" for a threat-actor pattern is the false
1782
+ // negative NEW-CTRL-052 defends against.
2046
1783
  const erroredPatterns = [];
2047
- // A 5xx / timeout / reset is retried with backoff + jitter (mirrors
2048
- // lib/source-advisories.js#fetchFeed); a permanent 4xx other than the
2049
- // rate-limit codes is not. 403/429 short-circuit to the rateLimited flag.
1784
+ // A 5xx, timeout or reset retries with backoff and jitter; a permanent 4xx does
1785
+ // not, and 403/429 short-circuit to the rateLimited flag.
2050
1786
  const retryable = (err) => {
2051
- // 403/429 are the documented rate-limit signal — surface them at once
2052
- // (preserving the prior continue-on-rate-limit semantics) rather than
2053
- // burning the retry budget; the operator re-runs with GITHUB_TOKEN set.
1787
+ // 403/429 are the documented rate-limit signal — surface at once rather than
1788
+ // burn the retry budget; the operator re-runs with GITHUB_TOKEN set.
2054
1789
  if (err && err._rateLimit) return false;
2055
1790
  if (err && typeof err.statusCode === 'number') return err.statusCode >= 500;
2056
1791
  if (err && (err.name === 'AbortError' || err.name === 'TimeoutError')) return true;
@@ -2095,9 +1830,8 @@ async function runWatchlistOrgScan(rawArgs = []) {
2095
1830
  });
2096
1831
  }
2097
1832
  } catch (err) {
2098
- // 403/429 (incl. after retries) is the documented rate-limit signal.
2099
- // Any other exhausted-retry failure marks the pattern errored so the
2100
- // operator can distinguish a dropped query from a clean no-match.
1833
+ // A non-rate-limit failure that exhausted its retries marks the pattern
1834
+ // errored, so a dropped query is distinguishable from a clean no-match.
2101
1835
  if (err && err._rateLimit) {
2102
1836
  rateLimited = true;
2103
1837
  } else {
@@ -2108,8 +1842,7 @@ async function runWatchlistOrgScan(rawArgs = []) {
2108
1842
  const generated_at = new Date().toISOString();
2109
1843
  if (outputFormat === 'json') {
2110
1844
  process.stdout.write(JSON.stringify({
2111
- // A rate-limited OR errored pattern means the scan is incomplete — a
2112
- // zero match-count is no longer trustworthy as "clean".
1845
+ // A rate-limited or errored pattern means a zero match-count is not "clean".
2113
1846
  ok: !rateLimited && erroredPatterns.length === 0,
2114
1847
  verb: 'watchlist',
2115
1848
  mode: 'org-scan',
@@ -2126,10 +1859,8 @@ async function runWatchlistOrgScan(rawArgs = []) {
2126
1859
  return;
2127
1860
  }
2128
1861
  if (outputFormat === 'markdown') {
2129
- // GitHub-flavored markdown table — paste-friendly for PR / issue
2130
- // bodies and security advisories. Includes the control reference
2131
- // and the unauthenticated / rate-limit caveats so the operator
2132
- // who receives the paste knows how to validate the result.
1862
+ // Paste-friendly for a PR, issue or advisory body. Carries the unauthenticated
1863
+ // and rate-limit caveats, so the receiver knows how far to trust the result.
2133
1864
  const lines = [];
2134
1865
  lines.push(`## GitHub Org-Scan: ${org}`);
2135
1866
  lines.push('');
@@ -2182,23 +1913,18 @@ async function runWatchlistOrgScan(rawArgs = []) {
2182
1913
  }
2183
1914
  }
2184
1915
 
2185
- // Only run the CLI when this file is executed directly. Earlier versions
2186
- // invoked main() at import time too, which meant `require('./orchestrator')`
2187
- // would trigger a full CLI dispatch (and printHelp) inside the importing
2188
- // process. Gating on require.main keeps the module safely importable from
2189
- // tests and from `bin/exceptd.js`'s programmatic entrypoints.
1916
+ // Gated on require.main so `require('./orchestrator')` does not trigger a CLI
1917
+ // dispatch inside the importing process.
2190
1918
  if (require.main === module) {
2191
1919
  main().catch(err => {
2192
- // Match the structured ok:false stderr contract the rest of the surface
2193
- // uses so a JSON consumer gets a parseable envelope on the fatal path too.
1920
+ // The same ok:false envelope on the fatal path, so a JSON consumer can parse it.
2194
1921
  console.error(JSON.stringify({ ok: false, verb: cmd, error: String((err && err.message) || err) }));
2195
1922
  process.exitCode = 1;
2196
1923
  });
2197
1924
  }
2198
1925
 
2199
1926
  module.exports = {
2200
- // Export the main + parseFlags helpers for programmatic embedders /
2201
- // tests. Verb runners stay internal — call them via the CLI surface.
1927
+ // Verb runners stay internal — reach them through the CLI surface.
2202
1928
  main,
2203
1929
  parseFlags,
2204
1930
  };