@blamejs/exceptd-skills 0.12.13 → 0.12.15

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 (87) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/bin/exceptd.js +147 -9
  3. package/data/_indexes/_meta.json +45 -45
  4. package/data/_indexes/activity-feed.json +4 -4
  5. package/data/_indexes/catalog-summaries.json +29 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +319 -76
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +18 -5
  23. package/lib/lint-skills.js +6 -1
  24. package/lib/playbook-runner.js +742 -78
  25. package/lib/refresh-external.js +40 -22
  26. package/lib/refresh-network.js +193 -17
  27. package/lib/scoring.js +20 -7
  28. package/lib/source-ghsa.js +219 -37
  29. package/lib/source-osv.js +381 -122
  30. package/lib/validate-catalog-meta.js +64 -9
  31. package/lib/validate-cve-catalog.js +56 -18
  32. package/lib/validate-indexes.js +88 -37
  33. package/lib/verify.js +72 -0
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest-snapshot.sha256 +1 -0
  36. package/manifest.json +73 -73
  37. package/orchestrator/dispatcher.js +21 -1
  38. package/orchestrator/event-bus.js +52 -8
  39. package/orchestrator/index.js +279 -20
  40. package/orchestrator/pipeline.js +63 -2
  41. package/orchestrator/scanner.js +32 -10
  42. package/orchestrator/scheduler.js +150 -17
  43. package/package.json +3 -1
  44. package/sbom.cdx.json +7 -7
  45. package/scripts/check-manifest-snapshot.js +32 -0
  46. package/scripts/check-sbom-currency.js +65 -3
  47. package/scripts/check-test-coverage.js +142 -19
  48. package/scripts/predeploy.js +83 -39
  49. package/scripts/refresh-manifest-snapshot.js +55 -4
  50. package/scripts/validate-vendor-online.js +169 -0
  51. package/scripts/verify-shipped-tarball.js +106 -3
  52. package/skills/ai-attack-surface/skill.md +18 -10
  53. package/skills/ai-c2-detection/skill.md +7 -2
  54. package/skills/ai-risk-management/skill.md +5 -4
  55. package/skills/api-security/skill.md +3 -3
  56. package/skills/attack-surface-pentest/skill.md +5 -5
  57. package/skills/cloud-security/skill.md +1 -1
  58. package/skills/compliance-theater/skill.md +8 -8
  59. package/skills/container-runtime-security/skill.md +1 -1
  60. package/skills/dlp-gap-analysis/skill.md +5 -1
  61. package/skills/email-security-anti-phishing/skill.md +1 -1
  62. package/skills/exploit-scoring/skill.md +18 -18
  63. package/skills/framework-gap-analysis/skill.md +6 -6
  64. package/skills/global-grc/skill.md +3 -2
  65. package/skills/identity-assurance/skill.md +2 -2
  66. package/skills/incident-response-playbook/skill.md +4 -4
  67. package/skills/kernel-lpe-triage/skill.md +21 -2
  68. package/skills/mcp-agent-trust/skill.md +17 -10
  69. package/skills/mlops-security/skill.md +2 -1
  70. package/skills/ot-ics-security/skill.md +1 -1
  71. package/skills/policy-exception-gen/skill.md +3 -3
  72. package/skills/pqc-first/skill.md +1 -1
  73. package/skills/rag-pipeline-security/skill.md +7 -3
  74. package/skills/researcher/skill.md +20 -3
  75. package/skills/sector-energy/skill.md +1 -1
  76. package/skills/sector-federal-government/skill.md +1 -1
  77. package/skills/sector-financial/skill.md +3 -3
  78. package/skills/sector-healthcare/skill.md +2 -2
  79. package/skills/security-maturity-tiers/skill.md +7 -7
  80. package/skills/skill-update-loop/skill.md +19 -3
  81. package/skills/supply-chain-integrity/skill.md +1 -1
  82. package/skills/threat-model-currency/skill.md +11 -11
  83. package/skills/threat-modeling-methodology/skill.md +3 -3
  84. package/skills/webapp-security/skill.md +1 -1
  85. package/skills/zeroday-gap-learn/skill.md +51 -7
  86. package/vendor/blamejs/_PROVENANCE.json +4 -1
  87. package/vendor/blamejs/worker-pool.js +38 -0
@@ -18,6 +18,10 @@
18
18
  * help Show this help
19
19
  */
20
20
 
21
+ const fsMod = require('fs');
22
+ const osMod = require('os');
23
+ const pathMod = require('path');
24
+
21
25
  const { scan } = require('./scanner');
22
26
  const { dispatch, routeQuery, getSkillContext } = require('./dispatcher');
23
27
  const { currencyCheck, initPipeline } = require('./pipeline');
@@ -27,6 +31,51 @@ const { start: startScheduler, stop: stopScheduler, runCurrencyNow } = require('
27
31
  const cmd = process.argv[2];
28
32
  const args = process.argv.slice(3);
29
33
 
34
+ /**
35
+ * Minimal argv parser shared by verbs that want a structured view of
36
+ * `process.argv.slice(2)` instead of repeated `.includes()` calls. Returns
37
+ * `{ flags: Set<string>, options: Map<string,string>, positionals: string[] }`
38
+ * where boolean flags (e.g. `--json`) land in `flags`, option flags with a
39
+ * value (e.g. `--log-file path.log` or `--log-file=path.log`) land in
40
+ * `options`, and the rest are positionals.
41
+ *
42
+ * Kept inline rather than reaching into `lib/refresh-external.js#parseArgs`
43
+ * because that helper is hard-coded to the `refresh` flag set; this one
44
+ * stays generic. Both follow the same convention so verbs stay consistent.
45
+ */
46
+ function parseFlags(argv, optionFlags) {
47
+ const optSet = new Set(optionFlags || []);
48
+ const flags = new Set();
49
+ const options = new Map();
50
+ const positionals = [];
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const tok = argv[i];
53
+ if (typeof tok !== 'string') continue;
54
+ if (tok.startsWith('--')) {
55
+ const eq = tok.indexOf('=');
56
+ if (eq !== -1) {
57
+ const key = tok.slice(0, eq);
58
+ const val = tok.slice(eq + 1);
59
+ if (optSet.has(key)) options.set(key, val);
60
+ else flags.add(tok);
61
+ } else if (optSet.has(tok)) {
62
+ const next = argv[i + 1];
63
+ if (next !== undefined && !next.startsWith('--')) {
64
+ options.set(tok, next);
65
+ i++;
66
+ } else {
67
+ options.set(tok, '');
68
+ }
69
+ } else {
70
+ flags.add(tok);
71
+ }
72
+ } else {
73
+ positionals.push(tok);
74
+ }
75
+ }
76
+ return { flags, options, positionals };
77
+ }
78
+
30
79
  async function main() {
31
80
  switch (cmd) {
32
81
  case 'scan':
@@ -48,7 +97,7 @@ async function main() {
48
97
  await runReport(args[0] || 'technical');
49
98
  break;
50
99
  case 'watch':
51
- runWatch();
100
+ await runWatch();
52
101
  break;
53
102
  case 'validate-cves':
54
103
  await runValidateCves(args);
@@ -56,7 +105,6 @@ async function main() {
56
105
  case 'validate-rfcs':
57
106
  await runValidateRfcs(args);
58
107
  break;
59
- case 'watch':
60
108
  case 'watchlist':
61
109
  runWatchlist(args);
62
110
  break;
@@ -162,7 +210,12 @@ Examples:
162
210
  // --- command implementations ---
163
211
 
164
212
  async function runScan() {
165
- const jsonOut = process.argv.includes('--json');
213
+ // Use the shared parseFlags helper so flag handling is consistent with
214
+ // other verbs (validate-cves, watchlist, etc.). Previously this was a
215
+ // bare `process.argv.includes('--json')`, which differed in style from
216
+ // the verbs below and could miss `--json=true` or similar future forms.
217
+ const { flags } = parseFlags(process.argv.slice(2), []);
218
+ const jsonOut = flags.has('--json');
166
219
  if (!jsonOut) console.log('[orchestrator] Scanning environment...\n');
167
220
  const result = await scan();
168
221
  if (jsonOut) {
@@ -407,29 +460,191 @@ async function runReport(format) {
407
460
  }
408
461
  }
409
462
 
410
- function runWatch() {
463
+ /**
464
+ * Resolve a writable directory for the watch lockfile. Prefer the operator's
465
+ * home directory; fall back to the OS tempdir when home is non-writable or
466
+ * non-existent (CI runners, restricted shells, etc.).
467
+ */
468
+ function _resolveWatchLockDir() {
469
+ const home = process.env.EXCEPTD_HOME || pathMod.join(osMod.homedir(), '.exceptd');
470
+ try {
471
+ fsMod.mkdirSync(home, { recursive: true });
472
+ // Probe writability with a small marker; remove on success.
473
+ const probe = pathMod.join(home, `.write-probe-${process.pid}`);
474
+ fsMod.writeFileSync(probe, '');
475
+ fsMod.unlinkSync(probe);
476
+ return home;
477
+ } catch {
478
+ const fallback = pathMod.join(osMod.tmpdir(), 'exceptd');
479
+ try { fsMod.mkdirSync(fallback, { recursive: true }); } catch { /* tmp always exists */ }
480
+ return fallback;
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Acquire an exclusive watch lockfile. Returns `{ path, release }` on
486
+ * success. Throws with code 'EWATCHLOCKED' when another watcher holds the
487
+ * lock and the lock looks fresh. Staleness is determined by two checks
488
+ * combined: (a) the recorded PID is no longer alive, or (b) the file
489
+ * mtime is older than 60s. Either path reclaims the lock atomically.
490
+ *
491
+ * The PID-alive check covers Windows, where SIGTERM cannot be delivered
492
+ * to the prior watcher and our graceful release handler never ran (e.g.
493
+ * the test harness kills with taskkill /F). Without it, every second
494
+ * watch invocation would inherit a stale lock until the 60s mtime ages out.
495
+ */
496
+ function _acquireWatchLock() {
497
+ const dir = _resolveWatchLockDir();
498
+ const lockPath = pathMod.join(dir, 'watch.lock');
499
+ const STALE_MS = 60_000;
500
+
501
+ function tryCreate() {
502
+ const fd = fsMod.openSync(lockPath, 'wx');
503
+ fsMod.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
504
+ fsMod.closeSync(fd);
505
+ }
506
+
507
+ function _pidAlive(pid) {
508
+ if (!Number.isInteger(pid) || pid <= 0) return false;
509
+ try {
510
+ // signal 0 is the POSIX "is the process alive" probe; Node implements
511
+ // it on Windows too via OpenProcess. Throws ESRCH (or EPERM, which
512
+ // also implies alive) when the PID is dead.
513
+ process.kill(pid, 0);
514
+ return true;
515
+ } catch (e) {
516
+ return e.code === 'EPERM';
517
+ }
518
+ }
519
+
520
+ try {
521
+ tryCreate();
522
+ } catch (err) {
523
+ if (err.code !== 'EEXIST') throw err;
524
+ let stale = false;
525
+ let recordedPid = null;
526
+ try {
527
+ const raw = fsMod.readFileSync(lockPath, 'utf8');
528
+ try { recordedPid = JSON.parse(raw).pid; } catch { /* malformed file = stale */ stale = true; }
529
+ const st = fsMod.statSync(lockPath);
530
+ const ageStale = (Date.now() - st.mtimeMs) > STALE_MS;
531
+ const pidStale = recordedPid != null && !_pidAlive(recordedPid);
532
+ stale = stale || ageStale || pidStale;
533
+ } catch {
534
+ stale = true;
535
+ }
536
+ if (!stale) {
537
+ const e = new Error(
538
+ `another exceptd watch process (pid ${recordedPid}) appears to hold ${lockPath}; remove the file if you're sure no watcher is running`
539
+ );
540
+ e.code = 'EWATCHLOCKED';
541
+ throw e;
542
+ }
543
+ // Stale — reclaim atomically. unlink + re-create with O_EXCL so a race
544
+ // with another reclaimer surfaces as EEXIST cleanly.
545
+ try { fsMod.unlinkSync(lockPath); } catch { /* concurrent reclaim, fine */ }
546
+ tryCreate();
547
+ }
548
+
549
+ return {
550
+ path: lockPath,
551
+ release() {
552
+ try { fsMod.unlinkSync(lockPath); } catch { /* idempotent */ }
553
+ }
554
+ };
555
+ }
556
+
557
+ async function runWatch() {
558
+ const { flags, options } = parseFlags(args, ['--log-file']);
559
+ const logFilePath = options.get('--log-file');
560
+ let logStream = null;
561
+ // Tee stdout when --log-file is set. We intercept process.stdout.write so
562
+ // every console.log + raw write also lands in the log file; this is the
563
+ // simplest pattern that captures scheduler + event-bus + this verb's
564
+ // output uniformly without rewriting every console.log call site.
565
+ if (logFilePath) {
566
+ try {
567
+ fsMod.mkdirSync(pathMod.dirname(pathMod.resolve(logFilePath)), { recursive: true });
568
+ logStream = fsMod.createWriteStream(pathMod.resolve(logFilePath), { flags: 'a' });
569
+ } catch (err) {
570
+ console.error(`[orchestrator] --log-file ${logFilePath}: ${err.message}`);
571
+ process.exitCode = 2;
572
+ return;
573
+ }
574
+ const origWrite = process.stdout.write.bind(process.stdout);
575
+ process.stdout.write = function teeWrite(chunk, enc, cb) {
576
+ try { logStream.write(chunk, enc); } catch { /* best-effort */ }
577
+ return origWrite(chunk, enc, cb);
578
+ };
579
+ }
580
+ // Touch flags to keep eslint-style linters quiet about unused locals.
581
+ void flags;
582
+
583
+ // Acquire the cross-process watch lock before any heavy work. The lock
584
+ // prevents two concurrent watchers from emitting double events or
585
+ // double-firing scheduler bootstraps. Release happens in every shutdown
586
+ // path (SIGINT/SIGTERM/SIGHUP/SIGBREAK).
587
+ let lock;
588
+ try {
589
+ lock = _acquireWatchLock();
590
+ } catch (err) {
591
+ console.error(`[orchestrator] cannot start watch: ${err.message}`);
592
+ process.exitCode = err.code === 'EWATCHLOCKED' ? 75 : 1;
593
+ return;
594
+ }
595
+
411
596
  console.log('[orchestrator] Starting event watcher...');
597
+ console.log(`[orchestrator] Lockfile: ${lock.path}`);
412
598
  console.log('Listening for: CISA KEV additions, ATLAS updates, CVE drops, framework amendments.\n');
413
599
 
414
- bus.onAny(event => {
600
+ // Save the listener reference so the shutdown path can detach it instead
601
+ // of leaving it bound (a leaked '*' listener accumulates across
602
+ // start/stop cycles in long-running embedders).
603
+ const anyListener = (event) => {
415
604
  console.log(`[event] ${event.type} — ${event.timestamp}`);
416
605
  if (event.affected_skills.length > 0) {
417
606
  console.log(` Affected skills: ${event.affected_skills.join(', ')}`);
418
607
  }
419
- if (event.payload.cve_id) {
608
+ if (event.payload && event.payload.cve_id) {
420
609
  console.log(` CVE: ${event.payload.cve_id}`);
421
610
  }
422
- });
611
+ };
612
+ bus.onAny(anyListener);
423
613
 
424
614
  startScheduler();
425
615
 
426
- process.on('SIGINT', () => {
427
- console.log('\n[orchestrator] Stopping watcher.');
428
- stopScheduler();
429
- process.exit(0);
430
- });
616
+ let shuttingDown = false;
617
+ const shutdown = (signal) => {
618
+ if (shuttingDown) return;
619
+ shuttingDown = true;
620
+ console.log(`\n[orchestrator] Stopping watcher (${signal}).`);
621
+ try { bus.offAny(anyListener); } catch { /* best-effort */ }
622
+ try { stopScheduler(); } catch { /* best-effort */ }
623
+ try { lock.release(); } catch { /* best-effort */ }
624
+ if (logStream) {
625
+ try { logStream.end(); } catch { /* best-effort */ }
626
+ }
627
+ // process.exitCode + return-from-handler pattern: let the loop drain
628
+ // (so stdout flushes the goodbye line + the log stream finishes) rather
629
+ // than a hard process.exit() that can truncate.
630
+ process.exitCode = 0;
631
+ };
431
632
 
432
- console.log('Press Ctrl+C to stop.\n');
633
+ // Standard signal coverage. SIGINT (Ctrl+C) was the only one previously
634
+ // handled; SIGTERM is the conventional "graceful stop" signal sent by
635
+ // process managers (systemd, docker stop, kubernetes), and SIGHUP is the
636
+ // terminal-close signal on POSIX. SIGBREAK fires on Ctrl+Break on Windows.
637
+ // SIGHUP doesn't exist on Windows and registering it there is a no-op
638
+ // that throws on some Node versions; gate by platform.
639
+ process.on('SIGINT', () => shutdown('SIGINT'));
640
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
641
+ if (process.platform !== 'win32') {
642
+ try { process.on('SIGHUP', () => shutdown('SIGHUP')); } catch { /* unsupported */ }
643
+ } else {
644
+ try { process.on('SIGBREAK', () => shutdown('SIGBREAK')); } catch { /* unsupported */ }
645
+ }
646
+
647
+ console.log('Press Ctrl+C to stop. (SIGTERM / SIGHUP / SIGBREAK also honored.)\n');
433
648
  }
434
649
 
435
650
  async function runValidateCves(rawArgs = []) {
@@ -444,6 +659,11 @@ async function runValidateCves(rawArgs = []) {
444
659
  // The cache layout is fixed by lib/prefetch.js — same one refresh-external
445
660
  // reads from.
446
661
  let cacheDir = null;
662
+ // --concurrency N — bound the number of in-flight upstream calls during
663
+ // live validation. Default stays 4 to match the prior implicit value;
664
+ // operators with faster networks can crank it up, air-gapped fleets can
665
+ // drop it to 1 to keep cache reads serial.
666
+ let concurrency = 4;
447
667
  for (let i = 0; i < rawArgs.length; i++) {
448
668
  const a = rawArgs[i];
449
669
  if (a === '--from-cache') {
@@ -452,6 +672,12 @@ async function runValidateCves(rawArgs = []) {
452
672
  if (next && !next.startsWith('--')) i++;
453
673
  } else if (a.startsWith('--from-cache=')) {
454
674
  cacheDir = a.slice('--from-cache='.length);
675
+ } else if (a === '--concurrency') {
676
+ const next = rawArgs[i + 1];
677
+ if (next !== undefined) { const n = Number(next); if (Number.isFinite(n) && n >= 1) concurrency = Math.floor(n); i++; }
678
+ } else if (a.startsWith('--concurrency=')) {
679
+ const n = Number(a.slice('--concurrency='.length));
680
+ if (Number.isFinite(n) && n >= 1) concurrency = Math.floor(n);
455
681
  }
456
682
  }
457
683
  if (cacheDir) cacheDir = path.resolve(cacheDir);
@@ -559,7 +785,7 @@ async function runValidateCves(rawArgs = []) {
559
785
  if (cacheDir && fs.existsSync(cacheDir)) {
560
786
  report = await validateAllCvesPreferCache(catalog, cacheDir);
561
787
  } else {
562
- report = await validateAllCves(catalog, { concurrency: 4 });
788
+ report = await validateAllCves(catalog, { concurrency });
563
789
  }
564
790
 
565
791
  // Index results by cve_id (validateAllCves preserves insertion order, but be explicit).
@@ -850,7 +1076,12 @@ function runWatchlist(rawArgs = []) {
850
1076
  process.exit(2);
851
1077
  }
852
1078
 
853
- const skills = Array.isArray(manifest.skills) ? manifest.skills : [];
1079
+ // Exclude entries that are explicitly marked `status: "deprecated"` so
1080
+ // operator forward-watch decisions aren't anchored to skills that are
1081
+ // about to come out of the manifest. The field is optional today; when
1082
+ // absent every skill is treated as active.
1083
+ const skills = (Array.isArray(manifest.skills) ? manifest.skills : [])
1084
+ .filter(s => s && s.status !== 'deprecated');
854
1085
  // item -> { skills: [{name, last_threat_review}] }
855
1086
  const itemToSkills = new Map();
856
1087
  // skill name -> { items: [...], last_threat_review }
@@ -958,11 +1189,25 @@ async function validateAllCvesPreferCache(catalog, cacheDir) {
958
1189
  const path = require('path');
959
1190
  const { validateCve } = require('../sources/validators');
960
1191
 
1192
+ // 50 MB cap on any single cache file. Refuses to read past the cap to
1193
+ // protect against a tampered or malformed prefetch payload that would
1194
+ // OOM the validator on JSON.parse. The cap is well above any legitimate
1195
+ // NVD / EPSS / KEV file (largest in practice is the full KEV feed at
1196
+ // ~3 MB as of 2026); anything beyond 50 MB is almost certainly damage.
1197
+ const CACHE_FILE_MAX_BYTES = 50 * 1024 * 1024;
1198
+
961
1199
  function readCached(source, id) {
962
1200
  const safe = id.replace(/[^A-Za-z0-9._-]/g, '_');
963
1201
  const p = path.join(cacheDir, source, `${safe}.json`);
964
1202
  if (!fs.existsSync(p)) return null;
965
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
1203
+ try {
1204
+ const st = fs.statSync(p);
1205
+ if (st.size > CACHE_FILE_MAX_BYTES) {
1206
+ console.error(`[validate-cves] cache file ${p} exceeds ${CACHE_FILE_MAX_BYTES} byte cap (${st.size}); refusing to read.`);
1207
+ return null;
1208
+ }
1209
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
1210
+ }
966
1211
  catch { return null; }
967
1212
  }
968
1213
 
@@ -1136,7 +1381,21 @@ Examples:
1136
1381
  `);
1137
1382
  }
1138
1383
 
1139
- main().catch(err => {
1140
- console.error('[orchestrator] Fatal:', err.message);
1141
- process.exit(1);
1142
- });
1384
+ // Only run the CLI when this file is executed directly. Earlier versions
1385
+ // invoked main() at import time too, which meant `require('./orchestrator')`
1386
+ // would trigger a full CLI dispatch (and printHelp) inside the importing
1387
+ // process. Gating on require.main keeps the module safely importable from
1388
+ // tests and from `bin/exceptd.js`'s programmatic entrypoints.
1389
+ if (require.main === module) {
1390
+ main().catch(err => {
1391
+ console.error('[orchestrator] Fatal:', err.message);
1392
+ process.exit(1);
1393
+ });
1394
+ }
1395
+
1396
+ module.exports = {
1397
+ // Export the main + parseFlags helpers for programmatic embedders /
1398
+ // tests. Verb runners stay internal — call them via the CLI surface.
1399
+ main,
1400
+ parseFlags,
1401
+ };
@@ -61,6 +61,18 @@ function initPipeline(triggerType, triggerPayload) {
61
61
  * @returns {object} Handoff package for the next stage
62
62
  */
63
63
  function buildHandoff(run, stageIndex, stageOutput) {
64
+ // Bounds-check the stage index up front. Without this, an out-of-range
65
+ // index throws an opaque "cannot read properties of undefined" deep inside
66
+ // validateHandoff. With it, callers see a structured error that names the
67
+ // exact invalid input.
68
+ if (!run || !Array.isArray(run.stages)) {
69
+ throw new TypeError('buildHandoff: run.stages must be an array');
70
+ }
71
+ if (!Number.isInteger(stageIndex) || stageIndex < 0 || stageIndex >= run.stages.length) {
72
+ throw new RangeError(
73
+ `buildHandoff: stageIndex ${stageIndex} out of range [0, ${run.stages.length})`
74
+ );
75
+ }
64
76
  const currentStage = run.stages[stageIndex];
65
77
  const nextStage = run.stages[stageIndex + 1];
66
78
 
@@ -96,8 +108,40 @@ function buildHandoff(run, stageIndex, stageOutput) {
96
108
  *
97
109
  * @returns {{ currency_report: object[], action_required: boolean }}
98
110
  */
111
+ // Manifest read cache. currencyCheck() runs on every weekly tick AND on every
112
+ // `exceptd currency` invocation; in `watch` mode the scheduler triggers it
113
+ // repeatedly. Re-reading + JSON.parse'ing the (~80 KB) manifest each time is
114
+ // pure waste when the file hasn't changed within the cache window. 60s TTL is
115
+ // short enough that a manual edit during a long-running watcher shows up by
116
+ // the next periodic tick.
117
+ const MANIFEST_CACHE_TTL_MS = 60_000;
118
+ let _manifestCache = { value: null, mtimeMs: 0, readAt: 0 };
119
+
120
+ function _loadManifestCached() {
121
+ const manifestPath = path.join(__dirname, '..', 'manifest.json');
122
+ const now = Date.now();
123
+ if (_manifestCache.value && (now - _manifestCache.readAt) < MANIFEST_CACHE_TTL_MS) {
124
+ // Within TTL — return cached value. The cost of a stat() per call is
125
+ // ~tens of microseconds; trade it for the JSON.parse() cost saved.
126
+ try {
127
+ const st = fs.statSync(manifestPath);
128
+ if (st.mtimeMs === _manifestCache.mtimeMs) {
129
+ return _manifestCache.value;
130
+ }
131
+ } catch {
132
+ // stat failed — fall through to re-read which will surface the error.
133
+ }
134
+ }
135
+ const raw = fs.readFileSync(manifestPath, 'utf8');
136
+ const parsed = JSON.parse(raw);
137
+ let mtimeMs = 0;
138
+ try { mtimeMs = fs.statSync(manifestPath).mtimeMs; } catch { /* leave 0 */ }
139
+ _manifestCache = { value: parsed, mtimeMs, readAt: now };
140
+ return parsed;
141
+ }
142
+
99
143
  function currencyCheck() {
100
- const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'manifest.json'), 'utf8'));
144
+ const manifest = _loadManifestCached();
101
145
  const now = new Date();
102
146
  const report = [];
103
147
 
@@ -204,4 +248,21 @@ function _currencyLabel(score) {
204
248
  return 'critical_stale';
205
249
  }
206
250
 
207
- module.exports = { initPipeline, buildHandoff, currencyCheck, getAgentDefinition };
251
+ // Test-only hook to reset the in-memory manifest cache. Not part of the
252
+ // operator surface. v0.12.14: moved out of the export object literal so
253
+ // the inline { mtimeMs: 0, readAt: 0 } reset values don't get detected
254
+ // by scripts/check-test-coverage.js's extractLibExports regex as
255
+ // pseudo-exports (`mtimeMs` and `readAt` are private cache fields, not
256
+ // module exports).
257
+ function _resetManifestCache() {
258
+ _manifestCache = { value: null, mtimeMs: 0, readAt: 0 };
259
+ }
260
+
261
+ module.exports = {
262
+ initPipeline,
263
+ buildHandoff,
264
+ currencyCheck,
265
+ getAgentDefinition,
266
+ MANIFEST_CACHE_TTL_MS,
267
+ _resetManifestCache,
268
+ };
@@ -55,7 +55,10 @@ async function scan() {
55
55
  findings.push(...frameworkScan());
56
56
 
57
57
  const summary = summarize(findings);
58
- return { timestamp, host, findings, summary, _deprecation: 'Use `exceptd plan` + `exceptd run <playbook>` for the seven-phase contract.' };
58
+ // _deprecation is a stderr-only banner it MUST NOT appear in the JSON
59
+ // shape that downstream consumers ingest. Internal narrative belongs on
60
+ // stderr (the banner above), never in the structured result body.
61
+ return { timestamp, host, findings, summary };
59
62
  }
60
63
 
61
64
  /**
@@ -205,18 +208,34 @@ function cryptoScan() {
205
208
  });
206
209
  }
207
210
 
208
- const tlsProbe = probeTls();
209
- if (tlsProbe) {
211
+ // Air-gap mode short-circuits the outbound TLS probe entirely; emit a
212
+ // skipped annotation so operators can see the probe was intentionally
213
+ // suppressed rather than failing silently.
214
+ if (process.env.EXCEPTD_AIR_GAP === '1') {
210
215
  findings.push({
211
216
  domain: 'crypto',
212
217
  signal: 'tls_probe',
213
- value: tlsProbe,
214
- severity: tlsProbe.includes('TLSv1.3') ? 'info' : 'high',
218
+ probe: 'skipped (air-gap)',
219
+ severity: 'info',
215
220
  skill_hint: 'pqc-first',
216
- action_required: tlsProbe.includes('TLSv1.3')
217
- ? 'TLS 1.3 detected — verify X25519+ML-KEM-768 hybrid for HNDL-exposed connections'
218
- : 'TLS below 1.3 — upgrade to TLS 1.3 minimum'
221
+ action_required: 'Air-gap mode active — TLS probe suppressed. Run with EXCEPTD_AIR_GAP=0 to probe.'
219
222
  });
223
+ } else {
224
+ const target = process.env.EXCEPTD_TLS_PROBE_TARGET || 'registry.npmjs.org:443';
225
+ const tlsProbe = probeTls(target);
226
+ if (tlsProbe) {
227
+ findings.push({
228
+ domain: 'crypto',
229
+ signal: 'tls_probe',
230
+ value: tlsProbe,
231
+ target,
232
+ severity: tlsProbe.includes('TLSv1.3') ? 'info' : 'high',
233
+ skill_hint: 'pqc-first',
234
+ action_required: tlsProbe.includes('TLSv1.3')
235
+ ? 'TLS 1.3 detected — verify X25519+ML-KEM-768 hybrid for HNDL-exposed connections'
236
+ : 'TLS below 1.3 — upgrade to TLS 1.3 minimum'
237
+ });
238
+ }
220
239
  }
221
240
 
222
241
  return findings;
@@ -483,8 +502,11 @@ function probePqcAlgorithms() {
483
502
  return result;
484
503
  }
485
504
 
486
- function probeTls() {
487
- const result = spawnSync('openssl', ['s_client', '-connect', 'google.com:443', '-brief'], {
505
+ function probeTls(target) {
506
+ // Target is "host:port"; default lives at the call site so the env var
507
+ // is read once per scan rather than baked in here.
508
+ const t = typeof target === 'string' && target.length > 0 ? target : 'registry.npmjs.org:443';
509
+ const result = spawnSync('openssl', ['s_client', '-connect', t, '-brief'], {
488
510
  input: '',
489
511
  timeout: 5000,
490
512
  stdio: ['pipe', 'pipe', 'pipe']