@blamejs/exceptd-skills 0.12.21 → 0.12.23

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.
@@ -48,7 +48,7 @@ const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
50
 
51
- // F7: cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
51
+ // cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
52
52
  // JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
53
53
  // failure, returns an empty stub, and accumulates the error in
54
54
  // getLoadErrors(). run() probes for accumulated load errors and returns
@@ -104,7 +104,7 @@ function loadPlaybook(playbookId) {
104
104
  return JSON.parse(fs.readFileSync(p, 'utf8'));
105
105
  }
106
106
 
107
- // E12: per-run playbook cache. Each phase function reads runOpts._playbookCache
107
+ // Per-run playbook cache. Each phase function reads runOpts._playbookCache
108
108
  // before falling back to loadPlaybook(). run() sets _playbookCache once at
109
109
  // entry so seven phases share one disk read + JSON parse instead of seven.
110
110
 
@@ -149,16 +149,17 @@ function deepMerge(a, b) {
149
149
  * 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
150
150
  * AND with the filesystem lockfile dir blocks the run.
151
151
  *
152
- * E5: when runOpts.strictPreconditions === true, warn-level outcomes
152
+ * When runOpts.strictPreconditions === true, warn-level outcomes
153
153
  * (precondition_warn, precondition_unverified with on_fail=warn or
154
- * skip_phase) are ESCALATED to halts. The function returns ok:false with
155
- * blocked_by='precondition' and an issues array containing
154
+ * skip_phase) are ESCALATED to halts. The function returns ok:false
155
+ * with blocked_by='precondition' and an issues array containing
156
156
  * precondition_halt entries. Callers wanting "CI gate: any unverified
157
157
  * precondition is a failure" pass strictPreconditions=true.
158
158
  *
159
- * E6: when a precondition with on_fail='skip_phase' fails, the issue carries
159
+ * When a precondition with on_fail='skip_phase' fails, the issue carries
160
160
  * skip_phase: 'detect' (default) so run() can route to a skipped-phase
161
- * placeholder rather than executing detect against a missing prerequisite.
161
+ * placeholder rather than executing detect against a missing
162
+ * prerequisite.
162
163
  */
163
164
  function preflight(playbook, runOpts = {}) {
164
165
  const issues = [];
@@ -185,7 +186,7 @@ function preflight(playbook, runOpts = {}) {
185
186
  if (submitted === undefined) {
186
187
  const submission_hint = `Submit precondition_checks in your evidence JSON, e.g. { "precondition_checks": { "${pc.id}": true } }. The runner lifts this into runOpts before the gate evaluates.`;
187
188
  if (strict) {
188
- // E5: strictPreconditions promotes unverified to halt regardless of
189
+ // strictPreconditions promotes unverified to halt regardless of
189
190
  // declared on_fail.
190
191
  issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
191
192
  return {
@@ -213,7 +214,7 @@ function preflight(playbook, runOpts = {}) {
213
214
  return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
214
215
  }
215
216
  if (strict) {
216
- // E5: warn-level + skip_phase outcomes escalate to halt under strict.
217
+ // Warn-level + skip_phase outcomes escalate to halt under strict.
217
218
  issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
218
219
  return {
219
220
  ok: false,
@@ -223,7 +224,7 @@ function preflight(playbook, runOpts = {}) {
223
224
  };
224
225
  }
225
226
  if (pc.on_fail === 'skip_phase') {
226
- // E6: emit a skip_phase field so run() can route to a skipped-phase
227
+ // Emit a skip_phase field so run() can route to a skipped-phase
227
228
  // placeholder. Default target phase is 'detect' (the most common
228
229
  // skip target — preconditions typically gate host-side detection).
229
230
  // Playbooks may override via pc.skip_phase.
@@ -266,11 +267,11 @@ function preflight(playbook, runOpts = {}) {
266
267
  return { ok: true, issues };
267
268
  }
268
269
 
269
- // F28: lockDir lives at a stable global path so two CLI invocations from
270
+ // lockDir lives at a stable global path so two CLI invocations from
270
271
  // different working directories still share lock state for cross-process
271
- // mutex enforcement. Pre-fix this used process.cwd(), which meant invoking
272
- // the same playbook from /tmp and from /home/user/project simultaneously
273
- // would each see an empty locks dir and both run unchallenged. The path
272
+ // mutex enforcement. A process.cwd()-relative dir would let invocations
273
+ // from /tmp and from /home/user/project simultaneously each see an empty
274
+ // locks dir and both run unchallenged. The path
274
275
  // keys on os.platform() so Windows/macOS/Linux locks live under separate
275
276
  // directories (avoids cross-platform stale-PID confusion when a host is
276
277
  // shared across OSes via networked FS). Override via EXCEPTD_LOCK_DIR for
@@ -287,6 +288,16 @@ function lockFilePath(playbookId) {
287
288
  catch { return null; }
288
289
  }
289
290
 
291
+ // Same-PID stale-lockfile reclaim threshold. A same-process orphan (e.g.
292
+ // an earlier run() that crashed without unlinking, or a try/catch that
293
+ // swallowed the release) older than this is presumed dead and reclaimed.
294
+ // 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
295
+ // that no legitimate playbook hold reaches it (govern/look/run phases
296
+ // complete well inside one second per playbook), short enough that a
297
+ // wedged process recovers within one CI step rather than the rest of its
298
+ // lifetime.
299
+ const STALE_LOCK_MS = 30_000;
300
+
290
301
  function acquireLock(playbookId) {
291
302
  const p = lockFilePath(playbookId);
292
303
  if (!p) return null;
@@ -299,16 +310,14 @@ function acquireLock(playbookId) {
299
310
  writePayload();
300
311
  return p;
301
312
  } catch (e) {
302
- // DD P1-3: stale-PID reclaim. Pre-fix the EEXIST path returned null
303
- // and callers proceeded UNLOCKED a process that crashed mid-run
304
- // left its lockfile behind and every subsequent invocation silently
305
- // ran without mutex protection. Mirror withCatalogLock's pattern:
306
- // parse the recorded pid, probe with `process.kill(pid, 0)`. ESRCH
307
- // means the holder is dead unlink and retry once. EPERM (alive,
308
- // different user) or any other condition: leave the lock alone and
309
- // return null with a diagnostic so the caller knows acquisition
310
- // failed because the lock is genuinely held (not because the FS is
311
- // broken or the playbook id is malformed).
313
+ // Stale-PID reclaim. Without it, a process that crashed mid-run
314
+ // leaves its lockfile behind and every subsequent invocation runs
315
+ // UNLOCKED. Mirror withCatalogLock's pattern: parse the recorded pid,
316
+ // probe with `process.kill(pid, 0)`. ESRCH means the holder is dead —
317
+ // unlink and retry once. EPERM (alive, different user) or any other
318
+ // condition: leave the lock alone and return null with a diagnostic so
319
+ // the caller knows acquisition failed because the lock is genuinely
320
+ // held (not because the FS is broken or the playbook id is malformed).
312
321
  if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
313
322
  try {
314
323
  const raw = fs.readFileSync(p, 'utf8');
@@ -322,6 +331,24 @@ function acquireLock(playbookId) {
322
331
  try { fs.unlinkSync(p); } catch {}
323
332
  try { writePayload(); return p; } catch { /* fall through */ }
324
333
  }
334
+ // Same-PID stale-lockfile reclaim. If the recorded pid is ours,
335
+ // the only way to escape an orphaned same-process lockfile is by
336
+ // mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
337
+ // (e.g. nested run() within one process) must still return null
338
+ // so the caller knows the lock is held. A fresh same-PID lockfile
339
+ // is reentrancy; one older than STALE_LOCK_MS is an orphan from
340
+ // a crashed prior hold (or a try/catch that swallowed the release)
341
+ // and must be reclaimed — otherwise the process can never acquire
342
+ // this lock again for the rest of its lifetime.
343
+ if (Number.isInteger(pid) && pid === process.pid) {
344
+ try {
345
+ const stat = fs.statSync(p);
346
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
347
+ try { fs.unlinkSync(p); } catch {}
348
+ try { writePayload(); return p; } catch { /* fall through */ }
349
+ }
350
+ } catch { /* stat failed — treat as held */ }
351
+ }
325
352
  } catch { /* unreadable lockfile — treat as held by a live process */ }
326
353
  }
327
354
  // Lock genuinely held (or filesystem error). Returning null keeps
@@ -332,9 +359,9 @@ function acquireLock(playbookId) {
332
359
  }
333
360
  }
334
361
 
335
- // DD P1-3: callers needing to distinguish "couldn't acquire because the
336
- // lock is genuinely held by a live process" from "couldn't acquire
337
- // because of an unexpected error" can use this thin diagnostic wrapper.
362
+ // Callers needing to distinguish "couldn't acquire because the lock is
363
+ // genuinely held by a live process" from "couldn't acquire because of an
364
+ // unexpected error" can use this thin diagnostic wrapper.
338
365
  // Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
339
366
  // The bare `acquireLock` keeps its historical null-on-failure contract.
340
367
  function acquireLockDiagnostic(playbookId) {
@@ -367,6 +394,26 @@ function acquireLockDiagnostic(playbookId) {
367
394
  return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
368
395
  }
369
396
  }
397
+ // Same-PID stale-lockfile reclaim (diagnostic variant). Same
398
+ // semantics as in acquireLock: a same-process lockfile older than
399
+ // STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
400
+ // is legitimate reentrancy and stays held.
401
+ if (Number.isInteger(pid) && pid === process.pid) {
402
+ let mtimeMs = null;
403
+ try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
404
+ if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
405
+ try { fs.unlinkSync(p); } catch {}
406
+ try {
407
+ fs.writeFileSync(p,
408
+ JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
409
+ { flag: 'wx' });
410
+ return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
411
+ } catch (e3) {
412
+ return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
413
+ }
414
+ }
415
+ return { ok: false, reason: 'held_by_self', lock_path: p, holder_pid: pid };
416
+ }
370
417
  return { ok: false, reason: 'held_by_live_pid', lock_path: p, holder_pid: pid };
371
418
  }
372
419
  return { ok: false, reason: 'fs_error', error: e && e.message, lock_path: p };
@@ -394,7 +441,7 @@ function pidAlive(pid) {
394
441
  function govern(playbookId, directiveId, runOpts = {}) {
395
442
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
396
443
  const g = resolvedPhase(playbook, directiveId, 'govern');
397
- // F12: sort jurisdiction obligations by window_hours ascending so the
444
+ // Sort jurisdiction obligations by window_hours ascending so the
398
445
  // tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
399
446
  // first. Operators reading the govern output for ack-time briefing need
400
447
  // the most urgent clock at the top of the list.
@@ -510,7 +557,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
510
557
  return null; // truly unknown — fall through
511
558
  };
512
559
 
513
- // E1: per-indicator FP-check attestation map. Operators submit
560
+ // Per-indicator FP-check attestation map. Operators submit
514
561
  // signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
515
562
  // to declare which named false_positive_checks_required[] entries on the
516
563
  // indicator have been satisfied. An unverified FP check downgrades the
@@ -525,12 +572,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
525
572
  let fpChecksUnsatisfied = null;
526
573
  if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
527
574
  verdict = override;
528
- // E1: gate 'hit' verdict on per-indicator false_positive_checks_required
575
+ // Gate 'hit' verdict on per-indicator false_positive_checks_required
529
576
  // satisfaction. The FP-check attestation arrives as a sibling key
530
577
  // '<id>__fp_checks' in signal_overrides; default behavior (no
531
578
  // attestation) treats every required FP check as UNSATISFIED.
532
579
  if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
533
- // BB P2-4: a hostile or buggy attestation may be a Proxy whose property
580
+ // A hostile or buggy attestation may be a Proxy whose property
534
581
  // accessors throw. The filter below reads `att[fpName]` for each
535
582
  // required check; an exception inside the read would crash detect()
536
583
  // and abort the entire run. Wrap the FP-check evaluation in a
@@ -539,13 +586,14 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
539
586
  // read) and surface a runtime_error so the operator sees why.
540
587
  try {
541
588
  const attestation = overrides[`${ind.id}__fp_checks`];
542
- // S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
589
+ // Arrays satisfy `typeof === 'object'` but are NOT a valid
543
590
  // attestation map. A submission like
544
591
  // signal_overrides: { sig__fp_checks: [true, true] }
545
- // would previously have its truthy entries matched via the index
592
+ // would otherwise have its truthy entries matched via the index
546
593
  // fallback (att['0'] === true), silently bypassing every FP-check
547
- // requirement. Reject arrays explicitly so they fall through to the
548
- // empty-attestation branch (every required check unsatisfied).
594
+ // requirement. Reject arrays explicitly so they fall through to
595
+ // the empty-attestation branch (every required check
596
+ // unsatisfied).
549
597
  const safeAtt = Array.isArray(attestation) ? null : attestation;
550
598
  const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
551
599
  const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
@@ -585,9 +633,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
585
633
  // host AI is responsible for that). With NO captured artifacts, this is
586
634
  // a clean empty submission — emit 'miss' so the run can reach
587
635
  // classification:'not_detected' rather than getting stuck inconclusive.
588
- // E2: pre-fix both arms emitted 'inconclusive', so a clean empty run
589
- // could never reach not_detected and theater_verdict stayed
590
- // 'pending_agent_run' forever.
636
+ // A clean empty run with no captured artifacts must emit 'miss' so
637
+ // classification can reach 'not_detected'; otherwise theater_verdict
638
+ // stays 'pending_agent_run' indefinitely.
591
639
  const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
592
640
  verdict = anyCaptured ? 'inconclusive' : 'miss';
593
641
  }
@@ -617,9 +665,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
617
665
  // confirmed they're all benign" without this override.
618
666
  const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
619
667
  const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
620
- // BB P2-1: any override that's a non-empty string but NOT in the allowlist
621
- // (e.g. 'present', 'unknown', '', ' detected ', 'Detected') must surface
622
- // as a runtime_error rather than silently falling through to engine-computed
668
+ // Any override that's a non-empty string but NOT in the allowlist (e.g.
669
+ // 'present', 'unknown', '', ' detected ', 'Detected') surfaces as a
670
+ // runtime_error rather than silently falling through to engine-computed
623
671
  // classification. Operators submitting case variants / whitespace-padded
624
672
  // strings deserve a clear diagnostic, not a quiet downgrade. Treat the
625
673
  // override as absent for classification purposes once recorded.
@@ -637,17 +685,17 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
637
685
  }
638
686
  const override = overrideIsInAllowlist ? rawOverride : undefined;
639
687
 
640
- // BB P1-1 / BB P1-2: extend the v0.12.19 S P1-B gate to refuse ALL
641
- // classification overrides (`detected`, `clean`, `not_detected`) when any
642
- // indicator was FP-downgraded. A submission that maps to `'not_detected'`
643
- // (either by literal `not_detected` OR by `'clean'`, which v0.12.19 mapped
644
- // to `'not_detected'` at this site) MUST NOT hide a `verdict: 'hit'`
645
- // indicator whose `false_positive_checks_required[]` were unattested
646
- // that's a strictly worse false-negative outcome than allowing 'detected'
647
- // through. Substitute 'inconclusive' and emit a runtime_error.
648
- // BB P2-2: record indicator IDs and an unsatisfied-checks count ONLY —
649
- // never the literal FP-check check-name strings (those are an attestation-
650
- // bypass hint for a hostile agent reading the runtime_errors).
688
+ // Refuse ALL classification overrides (`detected`, `clean`,
689
+ // `not_detected`) when any indicator was FP-downgraded. A submission
690
+ // that maps to `'not_detected'` (either literally or via `'clean'`,
691
+ // which maps to `'not_detected'` at this site) MUST NOT hide a
692
+ // `verdict: 'hit'` indicator whose `false_positive_checks_required[]`
693
+ // were unattested that's a strictly worse false-negative outcome than
694
+ // allowing 'detected' through. Substitute 'inconclusive' and emit a
695
+ // runtime_error.
696
+ // Record indicator IDs and an unsatisfied-checks count ONLY — never the
697
+ // literal FP-check check-name strings (those are an attestation-bypass
698
+ // hint for a hostile agent reading the runtime_errors).
651
699
  const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
652
700
 
653
701
  let classification;
@@ -706,7 +754,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
706
754
  indicators_evaluated_count: indicatorResults.length,
707
755
  classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
708
756
  submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
709
- // E9: pass through any flat-shape observation collisions detected at
757
+ // Pass through any flat-shape observation collisions detected at
710
758
  // normalize time so analyze() can publish them under
711
759
  // analyze.signal_origins_with_collisions.
712
760
  _signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
@@ -766,14 +814,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
766
814
  const cveRefs = playbook.domain.cve_refs || [];
767
815
  const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
768
816
  : (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
769
- // F17: distinguish OpenVEX/CycloneDX "drop entirely" dispositions
817
+ // Distinguish OpenVEX/CycloneDX "drop entirely" dispositions
770
818
  // (not_affected / false_positive) from "keep but annotate" dispositions
771
819
  // (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
772
820
  // is computed below from agentSignals.vex_fixed when the operator passes
773
821
  // it (CLI populates it from the VEX doc alongside vex_filter).
774
822
  const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
775
823
  : (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
776
- // F20: wrap xref.byCve() so a corrupt catalog (or transient missing-index
824
+ // Wrap xref.byCve() so a corrupt catalog (or transient missing-index
777
825
  // anomaly) surfaces as a runtime_error rather than crashing analyze().
778
826
  const _byCveSafe = (id) => {
779
827
  try { return xref.byCve(id); }
@@ -791,7 +839,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
791
839
  const vexDropped = vexFilter
792
840
  ? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
793
841
  : [];
794
- // F17: VEX-fixed CVEs remain in matched/catalog arrays but get annotated
842
+ // VEX-fixed CVEs remain in matched/catalog arrays but get annotated
795
843
  // with vex_status:'fixed' downstream so consumers see them as resolved.
796
844
  const vexFixedIds = vexFixed
797
845
  ? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
@@ -828,7 +876,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
828
876
  }
829
877
  }
830
878
 
831
- // F3: indicator-level cve_ref correlation. Indicators may declare a
879
+ // Indicator-level cve_ref correlation. Indicators may declare a
832
880
  // cve_ref (string OR string[]) naming CVEs whose presence the indicator
833
881
  // pattern-matches. When such an indicator fires AND the named CVE exists
834
882
  // in the catalog, the CVE joins matched_cves with correlated_via=
@@ -867,7 +915,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
867
915
  // carry a non-null correlated_via array; catalog_baseline_cves entries
868
916
  // carry correlated_via:null and a `note` clarifying the field's intent.
869
917
  const cveShape = (c, correlatedVia) => {
870
- // F17: annotate VEX-fixed CVEs with vex_status. matched_cves still
918
+ // Annotate VEX-fixed CVEs with vex_status. matched_cves still
871
919
  // includes them so audit trails and SBOM reports surface "we know this
872
920
  // is in scope but vendor declared it fixed."
873
921
  const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
@@ -904,26 +952,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
904
952
 
905
953
  // RWEP composition: start from the per-CVE rwep_score of evidence-correlated
906
954
  // matches (NOT catalog baseline) so RWEP base reflects what the operator's
907
- // evidence actually surfaced. F18: the "max" reduction across matched CVEs
908
- // is intentional — RWEP is a "worst-case real-world exploit priority", not
955
+ // evidence actually surfaced. The "max" reduction across matched CVEs is
956
+ // intentional — RWEP is a "worst-case real-world exploit priority", not
909
957
  // an arithmetic average. The most-exploitable CVE in the set drives the
910
958
  // base; secondary CVEs add via rwep_inputs adjustments below rather than
911
959
  // through base summing (which would double-count overlapping risk).
912
- // F17: vex_status='fixed' CVEs do NOT drive the base — vendor declared
913
- // them resolved. They still appear in matched_cves for audit traceability
914
- // but don't elevate RWEP.
960
+ // vex_status='fixed' CVEs do NOT drive the base — vendor declared them
961
+ // resolved. They still appear in matched_cves for audit traceability but
962
+ // don't elevate RWEP.
915
963
  const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
916
964
  const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
917
965
 
918
- // F5: rwep_factor semantics. Each rwep_input.weight is conditional on the
919
- // matched CVE having a corresponding attribute. Pre-fix, every weight fired
920
- // unconditionally when its signal_id indicator hit operators saw RWEP +25
921
- // for active_exploitation regardless of whether the matched CVE was actually
922
- // under active exploitation. Now we multiply weight by a factor in [0, 1]
923
- // derived from the first matched CVE's catalog attribute. blast_radius is
924
- // sourced from the analyze-phase blast_radius_score / 5 (rubric ceiling).
925
- // Negative weights (patch_available, live_patch_available) keep their sign
926
- // so a patched CVE deducts the full magnitude when the catalog confirms a
966
+ // rwep_factor semantics: each rwep_input.weight is conditional on the
967
+ // matched CVE having a corresponding attribute. Multiply weight by a
968
+ // factor in [0, 1] derived from the first matched CVE's catalog
969
+ // attribute so a weight only fires when its CVE-attribute supports it
970
+ // (e.g. active_exploitation +25 only when the matched CVE is under
971
+ // active exploitation). blast_radius is sourced from the analyze-phase
972
+ // blast_radius_score / 5 (rubric ceiling). Negative weights
973
+ // (patch_available, live_patch_available) keep their sign so a patched
974
+ // CVE deducts the full magnitude when the catalog confirms a
927
975
  // patch is available.
928
976
  //
929
977
  // Aliasing: playbooks ship rwep_factor values `public_poc` and
@@ -971,12 +1019,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
971
1019
  }
972
1020
  };
973
1021
 
974
- // F6: blast_radius_score validation. Pre-fix, when no agent signal was
975
- // supplied the runner silently defaulted to blast_rubric[0].blast_radius_score
976
- // typically the LOWEST-blast rubric entry which is the opposite of
977
- // safe-default. Now: no supplied value null + signal='default'. Supplied
978
- // value out of [0,5] null + signal='rejected' + runtime_error. Supplied
979
- // value in range → use it + signal='supplied'.
1022
+ // blast_radius_score validation. No supplied value null +
1023
+ // signal='default'. Supplied value out of [0,5] → null +
1024
+ // signal='rejected' + runtime_error. Supplied value in range use it +
1025
+ // signal='supplied'. The runner never defaults to a rubric entry — that
1026
+ // would be the opposite of safe-default when the rubric's lowest entry
1027
+ // is the LOWEST-blast row.
980
1028
  const blastRubric = an.blast_radius_model?.scoring_rubric || [];
981
1029
  let blastRadiusScore = null;
982
1030
  let blastRadiusSignal = 'default';
@@ -993,7 +1041,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
993
1041
  }
994
1042
  }
995
1043
  }
996
- // F5: use the first evidence-correlated CVE as the canonical attribute
1044
+ // Use the first evidence-correlated CVE as the canonical attribute
997
1045
  // source for factor scaling. If matchedCves is empty there's no per-CVE
998
1046
  // evidence to gate on. v0.12.15: the prior fallback was
999
1047
  // `factorCve = null` → every factor returned 0 → catalog-shape playbooks
@@ -1086,11 +1134,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1086
1134
  // detect.classification = inconclusive → theater_verdict = pending_agent_run
1087
1135
  // Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
1088
1136
  //
1089
- // F24: validate against an allowlist. Pre-fix, any free-text string the
1090
- // operator passed through agentSignals.theater_verdict was accepted, so
1091
- // downstream consumers (CSAF/SARIF/OpenVEX) emitted bundles with garbage
1092
- // verdicts like "TODO" or "let me think". Allowlist: clear, present,
1093
- // theater, pending_agent_run, unknown.
1137
+ // Validate agentSignals.theater_verdict against an allowlist so
1138
+ // downstream consumers (CSAF/SARIF/OpenVEX) never emit bundles with
1139
+ // garbage verdicts like "TODO" or free-text strings. Allowlist: clear,
1140
+ // present, theater, pending_agent_run, unknown.
1094
1141
  const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
1095
1142
  let theaterVerdict = agentSignals.theater_verdict;
1096
1143
  if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
@@ -1146,12 +1193,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1146
1193
  // matched_cves when surfacing "what CVEs is the operator actually
1147
1194
  // affected by based on submitted evidence?"
1148
1195
  catalog_baseline_cves: catalogBaselineEntries,
1149
- // F18: rwep base is reduced via Math.max across matched CVEs. Surface
1150
- // the reduction strategy as a discoverable field so operators reading the
1196
+ // rwep base is reduced via Math.max across matched CVEs. Surface the
1197
+ // reduction strategy as a discoverable field so operators reading the
1151
1198
  // bundle understand the semantics without grepping source.
1152
1199
  rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
1153
1200
  blast_radius_score: blastRadiusScore,
1154
- // F6: visible annotation of where blast_radius_score came from:
1201
+ // Visible annotation of where blast_radius_score came from:
1155
1202
  // 'supplied' — operator/agent provided a value in [0, 5].
1156
1203
  // 'default' — no value supplied; runner returned null (no rubric guess).
1157
1204
  // 'rejected' — value supplied but out of range; treated as default + runtime_error.
@@ -1162,7 +1209,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1162
1209
  audit_evidence: an.compliance_theater_check?.audit_evidence,
1163
1210
  reality_test: an.compliance_theater_check?.reality_test,
1164
1211
  verdict: theaterVerdict,
1165
- // F25: render verdict_text for both 'theater' AND 'present' verdicts
1212
+ // Render verdict_text for both 'theater' AND 'present' verdicts
1166
1213
  // ('present' is a synonym used by some playbooks for "theater is here").
1167
1214
  verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
1168
1215
  ? an.compliance_theater_check?.theater_verdict_if_gap
@@ -1184,14 +1231,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1184
1231
  ? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected / resolved / false_positive. They remain in cve-catalog.json; the disposition lives in the VEX file.`
1185
1232
  : "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
1186
1233
  } : null,
1187
- // E3: regex-eval failures surfaced here so operators can see WHICH
1234
+ // Regex-eval failures surfaced here so operators can see WHICH
1188
1235
  // condition expression crashed without the runner dying. Only present
1189
1236
  // when at least one evalCondition() call hit a regex exception during
1190
1237
  // this analyze pass; runOpts._runErrors is the same accumulator
1191
1238
  // populated by run() across all phases, so callers reading this field
1192
1239
  // see every regex problem in the run.
1193
1240
  runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
1194
- // E9: collisions when two flat-shape observations targeted the same
1241
+ // Collisions when two flat-shape observations targeted the same
1195
1242
  // indicator id. Empty when there were no collisions or no flat-shape
1196
1243
  // observations submitted.
1197
1244
  signal_origins_with_collisions: Array.isArray(agentSignals?._signal_origins_collisions) ? agentSignals._signal_origins_collisions.slice() : (Array.isArray(detectResult?._signal_origins_collisions) ? detectResult._signal_origins_collisions.slice() : [])
@@ -1201,8 +1248,8 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1201
1248
  /**
1202
1249
  * Extract VEX disposition sets from a CycloneDX/OpenVEX document.
1203
1250
  *
1204
- * F17: pre-fix this conflated OpenVEX `fixed` and `not_affected` into one
1205
- * "drop" set. They have different semantics:
1251
+ * OpenVEX `fixed` and `not_affected` must NOT collapse into a single
1252
+ * "drop" set they have different semantics:
1206
1253
  *
1207
1254
  * - not_affected / false_positive → drop from matched_cves entirely.
1208
1255
  * The vendor has formally declared the product not vulnerable; the CVE
@@ -1251,7 +1298,7 @@ function vexFilterFromDoc(doc) {
1251
1298
 
1252
1299
  function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
1253
1300
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
1254
- // E3: surface evalCondition regex errors raised here into the same
1301
+ // Surface evalCondition regex errors raised here into the same
1255
1302
  // run-wide accumulator that analyze() reads.
1256
1303
  const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
1257
1304
  const v = resolvedPhase(playbook, directiveId, 'validate');
@@ -1275,7 +1322,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1275
1322
  // weren't verified — the agent can surface that to the operator.
1276
1323
  if (!selected && paths.length) selected = paths[0];
1277
1324
 
1278
- // F26: selected_remediation selection logic:
1325
+ // selected_remediation selection logic:
1279
1326
  // 1. Iterate remediation_paths sorted by priority ASC (lower number =
1280
1327
  // higher priority per schema convention).
1281
1328
  // 2. Pick the FIRST path whose every precondition (evaluated against
@@ -1288,18 +1335,17 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1288
1335
  // precondition trace so operators can see why a higher-priority path was
1289
1336
  // skipped.
1290
1337
 
1291
- // F10: regression schedule. Pre-fix this returned a single ISO string;
1292
- // now returns a structured object with next_run + event_triggers +
1293
- // unparseable. Preserve backwards compatibility by keeping
1338
+ // Regression schedule. Returns a structured object with next_run +
1339
+ // event_triggers + unparseable. Backwards compatibility: keep
1294
1340
  // regression_next_run as the ISO string (or null) so existing CSAF /
1295
1341
  // attestation consumers don't break; expose the structured form
1296
1342
  // separately.
1297
1343
  const triggers = v.regression_trigger || [];
1298
1344
  const regressionResult = computeRegressionNextRun(triggers);
1299
1345
 
1300
- // F30: reason annotation for null next_run — operators see WHY a
1301
- // schedule didn't emit a calendar date (no day intervals declared,
1302
- // every trigger is event-driven, or every trigger was unparseable).
1346
+ // Reason annotation for null next_run — operators see WHY a schedule
1347
+ // didn't emit a calendar date (no day intervals declared, every trigger
1348
+ // is event-driven, or every trigger was unparseable).
1303
1349
  let nextRunReason = null;
1304
1350
  if (!regressionResult.next_run) {
1305
1351
  if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
@@ -1330,15 +1376,15 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1330
1376
  }
1331
1377
 
1332
1378
  /**
1333
- * F10: extended interval parser. Supports:
1379
+ * Extended interval parser. Supports:
1334
1380
  * <N>d — N days
1335
1381
  * <N>wk — N weeks
1336
1382
  * <N>mo — N calendar months (Date.setMonth semantics)
1337
1383
  * <N>yr — N calendar years
1338
1384
  * on_event — event-triggered, no date computed; surfaces in
1339
1385
  * regression_event_triggers[] for the consumer.
1340
- * Pre-fix, only Nd was honored; wk/mo/yr/on_event triggers were silently
1341
- * dropped, so a playbook declaring "regression on every release" or
1386
+ * Without all five forms, a playbook declaring "regression on every
1387
+ * release" or
1342
1388
  * "monthly review" lost its schedule entry.
1343
1389
  */
1344
1390
  function parseInterval(intervalStr, now) {
@@ -1426,12 +1472,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1426
1472
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1427
1473
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1428
1474
  );
1429
- // E7: thread runOpts through so computeClockStart can check
1475
+ // Thread runOpts through so computeClockStart can check
1430
1476
  // operator_consent.explicit before auto-stamping detect_confirmed.
1431
1477
  const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
1432
- // E7: when the clock event is detect_confirmed AND the classification
1433
- // matched AND the operator did NOT pass --ack, surface clock_pending_ack
1434
- // so the notification record is visibly waiting on acknowledgement.
1478
+ // When the clock event is detect_confirmed AND the classification
1479
+ // matched AND the operator did NOT pass --ack, surface
1480
+ // clock_pending_ack so the notification record is visibly waiting on
1481
+ // acknowledgement.
1435
1482
  const clockPendingAck = !clockStart
1436
1483
  && obligation?.clock_starts === 'detect_confirmed'
1437
1484
  && agentSignals?.detection_classification === 'detected'
@@ -1457,13 +1504,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1457
1504
  // Evidence the regulator expects attached (from the obligation, not
1458
1505
  // just the operator-facing recipient bundle on the notification entry).
1459
1506
  evidence_required: obligation?.evidence_required || na.evidence_attached || [],
1460
- // F14: track missing interpolation variables so operators see exactly
1507
+ // Track missing interpolation variables so operators see exactly
1461
1508
  // which template vars failed to resolve. Empty array when all
1462
1509
  // placeholders rendered cleanly.
1463
1510
  ...(function () {
1464
1511
  const missing = [];
1465
- // F20: analyzeFindingShape is a pure transform but defensive-wrap
1466
- // it so a malformed analyze result (missing matched_cves, etc.)
1512
+ // analyzeFindingShape is a pure transform but defensive-wrap it
1513
+ // so a malformed analyze result (missing matched_cves, etc.)
1467
1514
  // can't bring down the whole close phase. Failures surface in
1468
1515
  // runtime_errors via runOpts._runErrors when available.
1469
1516
  let findingShape;
@@ -1517,13 +1564,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1517
1564
  const extraFormats = Array.isArray(agentSignals._bundle_formats)
1518
1565
  ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
1519
1566
  : [];
1520
- // B: build every bundle once and reuse, so bundle_body and
1521
- // bundles_by_format[primary] are the same object identity (and hence
1522
- // identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
1523
- // invoked twice for the primary format and each invocation crystallised
1524
- // a fresh Date.now() — operators diffing bundle_body against
1525
- // bundles_by_format.<primary> saw spurious millisecond drift on
1526
- // tracking.initial_release_date / timestamp / current_release_date.
1567
+ // Build every bundle once and reuse, so bundle_body and
1568
+ // bundles_by_format[primary] share object identity (and timestamps).
1569
+ // Without memoisation, buildEvidenceBundle gets invoked twice for the
1570
+ // primary format and each invocation crystallises a fresh Date.now() —
1571
+ // operators diffing bundle_body against bundles_by_format.<primary> see
1572
+ // spurious millisecond drift on tracking.initial_release_date /
1573
+ // timestamp / current_release_date.
1527
1574
  const evidencePackage = c.evidence_package ? (() => {
1528
1575
  const issuedAt = new Date().toISOString();
1529
1576
  const builtFormats = new Map();
@@ -1534,7 +1581,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1534
1581
  return builtFormats.get(format);
1535
1582
  };
1536
1583
  const primaryBody = buildOnce(primaryFormat);
1537
- // audit CC P2-1: bundles_by_format must always be an object keyed by the
1584
+ // bundles_by_format must always be an object keyed by the
1538
1585
  // primary format, even when no extra formats were requested. Pre-fix it
1539
1586
  // was null in the single-format case, forcing downstream tooling into a
1540
1587
  // `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
@@ -1592,8 +1639,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1592
1639
  validate: validateResult,
1593
1640
  finding: analyzeFindingShape(analyzeResult),
1594
1641
  ...agentSignals,
1595
- // E3: surface evalCondition regex failures from the feeds_into chain
1596
- // into the same accumulator. Without this the regex failure happens but
1642
+ // Surface evalCondition regex failures from the feeds_into chain into
1643
+ // the same accumulator. Without this the regex failure happens but
1597
1644
  // analyze.runtime_errors[] never sees it.
1598
1645
  ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
1599
1646
  };
@@ -1618,7 +1665,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1618
1665
  exception: exception,
1619
1666
  regression_schedule: regressionSchedule,
1620
1667
  feeds_into: feeds,
1621
- // F21: feeds_into surfaces downstream playbook IDs whose preconditions
1668
+ // feeds_into surfaces downstream playbook IDs whose preconditions
1622
1669
  // were satisfied by this run. The runner does NOT automatically chain
1623
1670
  // into them — the agent / operator decides whether to invoke them.
1624
1671
  // Surface that contract on the result so consumers don't assume an
@@ -1627,7 +1674,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1627
1674
  };
1628
1675
  }
1629
1676
 
1630
- // E8: severity ladder for active_exploitation. The worst-of reduction lets
1677
+ // Severity ladder for active_exploitation. The worst-of reduction lets
1631
1678
  // analyzeFindingShape report the most-exploited CVE in the matched set, not
1632
1679
  // the first-encountered one. Higher index = worse.
1633
1680
  const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
@@ -1644,10 +1691,10 @@ function worstActiveExploitation(matchedCves) {
1644
1691
  return worst || 'unknown';
1645
1692
  }
1646
1693
 
1647
- // F4: severity ladder derived from rwep_adjusted. Playbooks reference
1648
- // `finding.severity` in feeds_into and escalation_criteria conditions but
1649
- // pre-fix analyzeFindingShape never emitted it, so those conditions silently
1650
- // resolved against undefined. Thresholds:
1694
+ // Severity ladder derived from rwep_adjusted. Playbooks reference
1695
+ // `finding.severity` in feeds_into and escalation_criteria conditions;
1696
+ // emit it so those conditions resolve against a real value rather than
1697
+ // undefined. Thresholds:
1651
1698
  // rwep >= 80 → critical
1652
1699
  // rwep >= 50 → high
1653
1700
  // rwep >= 20 → medium
@@ -1665,22 +1712,21 @@ function analyzeFindingShape(a) {
1665
1712
  const rwepAdjusted = a.rwep?.adjusted ?? 0;
1666
1713
  return {
1667
1714
  matched_cve_ids: matched.map(c => c.cve_id).join(', '),
1668
- // F19: sibling array form for consumers that want to iterate IDs
1669
- // without re-splitting the joined string. The joined form stays for
1670
- // backwards compatibility with notification-draft templates that
1671
- // interpolate `${matched_cve_ids}` verbatim.
1715
+ // Sibling array form for consumers that want to iterate IDs without
1716
+ // re-splitting the joined string. The joined form stays for backwards
1717
+ // compatibility with notification-draft templates that interpolate
1718
+ // `${matched_cve_ids}` verbatim.
1672
1719
  matched_cve_ids_array: matched.map(c => c.cve_id),
1673
1720
  matched_cve_count: matched.length,
1674
1721
  kev_listed_count: matched.filter(c => c.cisa_kev).length,
1675
- // E8: previously this used .find() which returned the first matched CVE
1676
- // with a truthy active_exploitation. With two CVEs where #1 is
1677
- // 'suspected' and #2 is 'confirmed', operators saw 'suspected' on
1678
- // notification drafts — under-stating the threat. Now reduce to the
1679
- // worst rank across all matched CVEs.
1722
+ // Reduce active_exploitation to the worst rank across all matched
1723
+ // CVEs. A .find() lookup would return the first truthy entry — e.g.
1724
+ // 'suspected' on CVE #1 when CVE #2 is 'confirmed' under-stating
1725
+ // the threat in notification drafts.
1680
1726
  active_exploitation: worstActiveExploitation(matched),
1681
1727
  rwep_adjusted: rwepAdjusted,
1682
1728
  rwep_base: a.rwep?.base ?? 0,
1683
- // F4: severity surface for playbook conditions.
1729
+ // Severity surface for playbook conditions.
1684
1730
  severity: severityForRwep(rwepAdjusted),
1685
1731
  blast_radius_score: a.blast_radius_score ?? 0,
1686
1732
  framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
@@ -1722,7 +1768,7 @@ function buildProductBinding(playbook, sessionId) {
1722
1768
  // surface at least one candidate when any is known. Returns null when no
1723
1769
  // candidate exists — caller MUST omit `locations` rather than emit empty.
1724
1770
  //
1725
- // A: source segments are heterogeneous — many playbook artifacts
1771
+ // Source segments are heterogeneous — many playbook artifacts
1726
1772
  // describe a shell-command capture (`uname -r`) or human prose, not a real
1727
1773
  // file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
1728
1774
  // (RFC 3986); shell-command text + prose breaks downstream consumers
@@ -1779,28 +1825,57 @@ function getEngineVersion() {
1779
1825
  return _CACHED_PKG_VERSION;
1780
1826
  }
1781
1827
 
1782
- // audit CC P1-3 / P1-4: operator-supplied identity strings (--operator) and
1783
- // publisher namespace URLs (--publisher-namespace) flow into operator-facing
1784
- // CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass
1785
- // bin/exceptd.js already validates the inputs, but the runner is also called
1786
- // from library consumers that may bypass the CLI surface.
1828
+ // Operator-supplied identity strings (--operator) and publisher namespace
1829
+ // URLs (--publisher-namespace) flow into operator-facing CSAF surfaces.
1830
+ // Strip ASCII control characters as defence in depth bin/exceptd.js
1831
+ // already validates the CLI inputs, but the runner is also called from
1832
+ // library consumers that may bypass the CLI surface.
1833
+ //
1834
+ // Strip Unicode bidi / format / control / surrogate / private-use /
1835
+ // unassigned categories (\p{C} under the `u` regex flag) so direct
1836
+ // library callers of buildEvidenceBundle cannot smuggle a U+202E "RTL
1837
+ // OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
1838
+ // already refuses. NFC-normalise first so a decomposed sequence can't
1839
+ // combine past the codepoint check; cap the result at 256 codepoints
1840
+ // (NOT UTF-16 code units) so a string of astral-plane codepoints can't
1841
+ // smuggle a longer-than-256-display string past the cap by exploiting
1842
+ // JavaScript's surrogate-pair string length. Returns null on rejection
1843
+ // (empty after strip, or NFC normalise threw); callers (the
1844
+ // publisher-namespace + contact_details + tracking.generator sites)
1845
+ // treat null as "operator-unclaimed" and route through the existing
1846
+ // fallback (publisher.namespace = urn:exceptd:operator:unknown +
1847
+ // bundle_publisher_unclaimed runtime warning).
1787
1848
  function sanitizeOperatorText(s) {
1788
1849
  if (typeof s !== 'string') return null;
1789
- // eslint-disable-next-line no-control-regex
1790
- const cleaned = s.replace(/[\x00-\x1F\x7F]/g, '').trim();
1791
- return cleaned.length ? cleaned.slice(0, 256) : null;
1850
+ // NFC first: a Cf codepoint may be expressed as a base + combining mark
1851
+ // that recomposes into the format category under NFC. Normalise so the
1852
+ // strip catches it.
1853
+ let normalised;
1854
+ try { normalised = s.normalize('NFC'); }
1855
+ catch { return null; }
1856
+ // Strip every Unicode codepoint matching General Category C
1857
+ // (Cc, Cf, Cs, Co, Cn). \p{C} under the `u` flag matches all five.
1858
+ const stripped = normalised.replace(/\p{C}/gu, '');
1859
+ const trimmed = stripped.trim();
1860
+ if (trimmed.length === 0) return null;
1861
+ // Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
1862
+ // units, so a 256-codepoint astral-plane string isn't silently extended
1863
+ // past the cap by surrogate-pair encoding).
1864
+ const cps = Array.from(trimmed);
1865
+ if (cps.length <= 256) return cps.join('');
1866
+ return cps.slice(0, 256).join('');
1792
1867
  }
1793
1868
 
1794
1869
  function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
1795
1870
  runOpts = runOpts || {};
1796
1871
  const playbookSlug = urnSlug(playbook._meta.id);
1797
1872
  const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1798
- // B: pin one `now` value per bundle build (and accept an
1873
+ // Pin one `now` value per bundle build (and accept an
1799
1874
  // upstream-provided issuedAt) so multi-format emit produces identical
1800
1875
  // tracking timestamps across CSAF / OpenVEX / SARIF when close() is
1801
1876
  // building several formats from the same run. Without the parameter,
1802
- // each invocation crystallised a fresh `Date.now()` and bundle_body
1803
- // versus bundles_by_format[primary] would diverge on milliseconds.
1877
+ // each invocation crystallises a fresh `Date.now()` and bundle_body
1878
+ // versus bundles_by_format[primary] diverge on milliseconds.
1804
1879
  const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
1805
1880
 
1806
1881
  // CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
@@ -1819,24 +1894,24 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1819
1894
  name: productName,
1820
1895
  product_identification_helper: { purl: productPurl }
1821
1896
  }];
1822
- // A: `fixed` product_status MUST reflect operator-supplied VEX
1823
- // disposition (vex_status === 'fixed' — see analyze() F17), not the
1824
- // catalog's global `live_patch_available` flag. The catalog flag means
1825
- // "vendor publishes a live-patch in the world", not "operator deployed
1826
- // it on this host". Pre-fix the CSAF emitter declared every
1827
- // live-patchable CVE as fixed regardless of whether the operator's
1828
- // evidence actually showed the patch applied, producing CSAF documents
1829
- // that lied to downstream NVD / Red Hat dashboards. When
1830
- // live_patch_available is the only signal, status stays known_affected
1831
- // and the live-patch route is surfaced as a `vendor_fix` remediation.
1832
- // audit CC P1-2: CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1897
+ // `fixed` product_status MUST reflect operator-supplied VEX
1898
+ // disposition (vex_status === 'fixed' — see analyze()), not the
1899
+ // catalog's global `live_patch_available` flag. The catalog flag
1900
+ // means "vendor publishes a live-patch in the world", not "operator
1901
+ // deployed it on this host". Declaring every live-patchable CVE as
1902
+ // fixed regardless of operator evidence would produce CSAF documents
1903
+ // that lie to downstream NVD / Red Hat dashboards. When
1904
+ // live_patch_available is the only signal, status stays
1905
+ // known_affected and the live-patch route is surfaced as a
1906
+ // `vendor_fix` remediation.
1907
+ // CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1833
1908
  // regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
1834
1909
  // identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
1835
1910
  // validators (BSI CSAF validator, ENISA dashboard) refuse documents that
1836
1911
  // place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
1837
1912
  // to the `ids[]` array with a real `system_name`.
1838
1913
  //
1839
- // audit CC P2-2: CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
1914
+ // CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
1840
1915
  // cvss_v3 score block is emitted. Drop the entire score block when the
1841
1916
  // catalog has no CVSS data (score AND vector both unset); otherwise
1842
1917
  // include version + baseScore + vectorString + baseSeverity from the
@@ -1853,21 +1928,33 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1853
1928
  if (typeof vec !== 'string') return '3.1';
1854
1929
  const m = vec.match(/^CVSS:(\d+\.\d+)\//);
1855
1930
  if (!m) return '3.1';
1856
- // CSAF cvss_v3 block only accepts 3.x; if the catalog vector is 2.0 or
1857
- // 4.0 we still tag the block as the value the catalog declared. Strict
1858
- // validators that gate cvss_v3 to 3.0/3.1 will reject 2.0/4.0 but
1859
- // emitting the wrong version on a 4.0 vector would be worse.
1931
+ // Returns the declared version verbatim. The CALLER is responsible for
1932
+ // gating cvss_v3 emission to 3.0 / 3.1 per CSAF 2.0 schema. 2.0 and
1933
+ // 4.0 vectors are tagged here for diagnostic clarity but never reach
1934
+ // the cvss_v3 block downstream.
1860
1935
  return m[1];
1861
1936
  };
1862
1937
  const csafIdsFor = (id) => {
1863
- if (typeof id !== 'string' || !id) return { system_name: 'OSV', text: String(id) };
1864
- if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1865
- if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1866
- if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1867
- if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1868
- // Fallback: surface the raw value under a generic OSV system_name; any
1869
- // strict validator will at least know it's not a CVE.
1870
- return { system_name: 'OSV', text: id };
1938
+ // null / undefined / non-string id MUST NOT emit literal "null" /
1939
+ // "undefined" text into the vulnerabilities[] entry. String(id)
1940
+ // would coerce both to those literals; strict validators then
1941
+ // reject the document and operators see a phantom "null" CVE in
1942
+ // dashboards. Return null so the caller skips the entry entirely
1943
+ // and surfaces a runtime_error for the missing id.
1944
+ if (typeof id !== 'string' || !id) return null;
1945
+ if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1946
+ if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1947
+ if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1948
+ if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1949
+ // RUSTSEC advisories carry their own tracking authority
1950
+ // (https://rustsec.org); mis-routing them to system_name 'OSV'
1951
+ // loses the upstream provenance link and confuses downstream
1952
+ // ingesters that resolve by (system_name, text) pair.
1953
+ if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
1954
+ // Genuinely-unknown prefix surfaces as `exceptd-unknown` so
1955
+ // downstream ingesters see that the authority wasn't recognised
1956
+ // rather than misattributing every unknown id to OSV.
1957
+ return { system_name: 'exceptd-unknown', text: id };
1871
1958
  };
1872
1959
  const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
1873
1960
 
@@ -1879,18 +1966,60 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1879
1966
  || (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
1880
1967
  product_ids: [productId],
1881
1968
  }];
1882
- // audit CC P2-2: only emit cvss_v3 score block when we have a real
1969
+ // Catalog entries with a missing / non-string cve_id would
1970
+ // otherwise produce literal `text: "null"` / `text: "undefined"`
1971
+ // entries under ids[]. Skip the vulnerability entry entirely and
1972
+ // surface a runtime_error so the catalog gap is visible to
1973
+ // operators / CI gates.
1974
+ const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
1975
+ let idEntry = null;
1976
+ if (!idIsCve) {
1977
+ idEntry = csafIdsFor(c.cve_id);
1978
+ if (idEntry == null) {
1979
+ if (Array.isArray(runOpts._runErrors)) {
1980
+ const alreadyMissing = runOpts._runErrors.some(e => e && e.kind === 'bundle_cve_id_missing');
1981
+ if (!alreadyMissing) {
1982
+ runOpts._runErrors.push({
1983
+ kind: 'bundle_cve_id_missing',
1984
+ reason: 'A matched_cves[] entry has no string cve_id (null / undefined / non-string). The CSAF vulnerability entry was omitted to avoid emitting literal "null" / "undefined" text under vulnerabilities[].ids[].',
1985
+ remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
1986
+ });
1987
+ }
1988
+ }
1989
+ return null;
1990
+ }
1991
+ }
1992
+ // only emit cvss_v3 score block when we have a real
1883
1993
  // vector string AND a numeric score. Pre-fix every vuln carried
1884
1994
  // `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
1885
1995
  // signal — strict validators reject the truncated block, and
1886
1996
  // `base_score: 0` was a downstream-misleading default that suggested
1887
1997
  // an authoritative "informational" score where there was simply no
1888
1998
  // data.
1999
+ //
2000
+ // CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1. Catalog
2001
+ // vectors prefixed CVSS:2.0/ or CVSS:4.0/ would otherwise emit a
2002
+ // cvss_v3 block with version: '2.0' / '4.0', which strict
2003
+ // validators (BSI CSAF Validator) reject outright. Drop the block
2004
+ // for non-3.x vectors and surface a runtime_error so operators can
2005
+ // see why their CVSS data didn't make it through.
1889
2006
  const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
1890
- const scores = hasCvss ? [{
2007
+ const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
2008
+ const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
2009
+ if (hasCvss && !cvssV3Eligible && Array.isArray(runOpts._runErrors)) {
2010
+ const alreadyUnsup = runOpts._runErrors.some(e => e && e.kind === 'bundle_cvss_v3_version_unsupported');
2011
+ if (!alreadyUnsup) {
2012
+ runOpts._runErrors.push({
2013
+ kind: 'bundle_cvss_v3_version_unsupported',
2014
+ reason: `Catalog entry carries CVSS vector with version ${vectorVersion}; CSAF 2.0 cvss_v3 block only accepts versions 3.0 / 3.1. The score block was omitted from this vulnerability to keep the document valid against strict CSAF validators.`,
2015
+ remediation: 'Backfill a CVSS 3.1 vector against this CVE in the catalog, or wait for CSAF 2.1 (cvss_v4 support) — exceptd targets CSAF 2.0 today.'
2016
+ });
2017
+ }
2018
+ }
2019
+ const scores = cvssV3Eligible ? [{
1891
2020
  products: [productId],
1892
2021
  cvss_v3: {
1893
- version: csafCvssVersionFromVector(c.cvss_vector),
2022
+ version: vectorVersion,
1894
2023
  baseScore: c.cvss_score,
1895
2024
  vectorString: c.cvss_vector,
1896
2025
  baseSeverity: csafCvssSeverity(c.cvss_score),
@@ -1902,12 +2031,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1902
2031
  remediations,
1903
2032
  product_status: isFixed ? { fixed: [productId] } : { known_affected: [productId] }
1904
2033
  };
1905
- // audit CC P1-2: route by id shape.
1906
- if (CSAF_CVE_RE.test(c.cve_id)) {
2034
+ // route by id shape.
2035
+ if (idIsCve) {
1907
2036
  return { cve: c.cve_id, ...base };
1908
2037
  }
1909
- return { ids: [csafIdsFor(c.cve_id)], ...base };
1910
- });
2038
+ return { ids: [idEntry], ...base };
2039
+ }).filter(v => v != null);
1911
2040
  const indicatorVulns = indicatorHits.map(i => ({
1912
2041
  // CSAF `system_name` values land in operator-facing validators; the
1913
2042
  // "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
@@ -1918,15 +2047,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1918
2047
  remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
1919
2048
  product_status: { known_affected: [productId] }
1920
2049
  }));
1921
- // D: framework-gap entries used to ride in `vulnerabilities[]`
1922
- // with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
1923
- // `system_name` slot is reserved for recognised vulnerability tracking
1924
- // authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
1925
- // every downstream CSAF consumer (NVD ingester, Red Hat dashboard,
1926
- // ENISA validator) flagged every run for unknown ids and rendered
1927
- // false-positive advisories at the framework_gap_mapping length. Now
1928
- // framework gaps land in `document.notes[]` with `category: details`
1929
- // where they belong as advisory context, not pseudo-CVEs.
2050
+ // Framework-gap entries land in `document.notes[]` with
2051
+ // `category: details` rather than `vulnerabilities[]` with
2052
+ // `ids: [{ system_name: 'exceptd-framework-gap' }]`. The `system_name`
2053
+ // slot is reserved for recognised vulnerability tracking authorities
2054
+ // (CVE, GHSA, etc.); exceptd-framework-gap is not one, and every
2055
+ // downstream CSAF consumer (NVD ingester, Red Hat dashboard, ENISA
2056
+ // validator) would flag the run for unknown ids and render
2057
+ // false-positive advisories at the framework_gap_mapping length.
2058
+ // Notes are the right home for advisory context that is not itself
2059
+ // a pseudo-CVE.
1930
2060
  const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
1931
2061
  const lines = [
1932
2062
  `Framework: ${g.framework}`,
@@ -1940,7 +2070,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1940
2070
  text: lines.join('\n'),
1941
2071
  };
1942
2072
  });
1943
- // audit CC P1-3: CSAF §3.1.7.4 publisher.namespace MUST be the trust
2073
+ // CSAF §3.1.7.4 publisher.namespace MUST be the trust
1944
2074
  // anchor of the entity publishing the advisory — the OPERATOR running the
1945
2075
  // scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
1946
2076
  // claimed https://exceptd.com as namespace, falsely attributing
@@ -1967,7 +2097,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1967
2097
  title: 'Publisher namespace not supplied',
1968
2098
  text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
1969
2099
  }] : [];
1970
- // audit CC P1-3: ALSO surface the unclaimed-publisher condition through
2100
+ // ALSO surface the unclaimed-publisher condition through
1971
2101
  // the structured runtime_errors[] accumulator so machine-readable
1972
2102
  // consumers (CI gates, dashboards) can branch on it without parsing
1973
2103
  // notes[] prose. The orchestrator's post-close pass folds late-pushed
@@ -1986,7 +2116,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1986
2116
  }
1987
2117
  }
1988
2118
 
1989
- // audit CC P1-4: thread the validated --operator name into
2119
+ // thread the validated --operator name into
1990
2120
  // tracking.generator (engine identity) AND publisher.contact_details
1991
2121
  // (operator-of-record). engine.version is read from the package once per
1992
2122
  // process. contact_details is omitted when no operator was supplied so
@@ -1998,7 +2128,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1998
2128
  };
1999
2129
  if (operatorClean) publisherBlock.contact_details = operatorClean;
2000
2130
 
2001
- // audit CC P1-1: CSAF §3.1.11.3.5.1 defines `final` as an immutable
2131
+ // CSAF §3.1.11.3.5.1 defines `final` as an immutable
2002
2132
  // advisory; subsequent re-emits against the same tracking.id are
2003
2133
  // refused by strict validators (BSI CSAF Validator). Runtime detection
2004
2134
  // runs with no operator review loop are inherently revisable, so the
@@ -2028,7 +2158,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2028
2158
  id: `exceptd-${playbook._meta.id}-${sessionId}`,
2029
2159
  status: csafStatus,
2030
2160
  version: playbook._meta.version,
2031
- // audit CC P1-4: name the engine that emitted the advisory.
2161
+ // name the engine that emitted the advisory.
2032
2162
  // CSAF §3.1.11.3.2 places this under tracking.generator.engine.
2033
2163
  generator: {
2034
2164
  engine: { name: 'exceptd', version: getEngineVersion() },
@@ -2066,7 +2196,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2066
2196
  // render empty fields.
2067
2197
  if (format === 'sarif' || format === 'sarif-2.1.0') {
2068
2198
  const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
2069
- // audit CC P2-6: SARIF rule ids are global within a single sarif-log run.
2199
+ // SARIF rule ids are global within a single sarif-log run.
2070
2200
  // Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
2071
2201
  // across playbooks) collided when results from multiple playbook runs
2072
2202
  // were merged into one SARIF document — GitHub Code Scanning de-dupes
@@ -2144,8 +2274,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2144
2274
  } },
2145
2275
  results: [...cveResults, ...indicatorResults, ...gapResults],
2146
2276
  invocations: [{ executionSuccessful: true, properties: stripNulls({
2147
- // A: apply the B7 stripNulls contract here too — the
2148
- // `remediation` field is null for any run that didn't surface a
2277
+ // Apply the stripNulls contract here too — the `remediation`
2278
+ // field is null for any run that didn't surface a
2149
2279
  // selected_remediation, and SARIF viewers render null property
2150
2280
  // values as visible empty rows. Same helper as the result
2151
2281
  // property bags above.
@@ -2173,11 +2303,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2173
2303
  // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
2174
2304
  // they pass IRI validation in downstream VEX consumers.
2175
2305
  if (format === 'openvex' || format === 'openvex-0.2.0') {
2176
- // B: reuse the bundle-wide `now` so OpenVEX `timestamp`
2177
- // aligns with CSAF `document.tracking.initial_release_date` when both
2178
- // formats are emitted in the same close() pass. Pre-fix each format
2179
- // crystallised its own Date.now() value, and the two bundles in
2180
- // bundles_by_format disagreed on milliseconds.
2306
+ // Reuse the bundle-wide `now` so OpenVEX `timestamp` aligns with
2307
+ // CSAF `document.tracking.initial_release_date` when both formats are
2308
+ // emitted in the same close() pass. A per-format Date.now() would
2309
+ // cause the two bundles in bundles_by_format to disagree on
2310
+ // milliseconds.
2181
2311
  const issued = now;
2182
2312
  const productEntry = {
2183
2313
  '@id': productPurl,
@@ -2193,17 +2323,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2193
2323
  if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
2194
2324
  return fallback;
2195
2325
  };
2196
- // A: same `vex_status === 'fixed'` correctness rule as the
2197
- // CSAF emitter. The catalog `live_patch_available` flag is a global
2326
+ // Same `vex_status === 'fixed'` correctness rule as the CSAF
2327
+ // emitter. The catalog `live_patch_available` flag is a global
2198
2328
  // "vendor publishes a live-patch" signal, not an operator-host
2199
- // disposition. Treating it as `status: fixed` made OpenVEX statements
2200
- // claim resolution that the operator hadn't actually attested to.
2201
- // VEX consumers downstream of CISA / SBOM / supply-chain pipelines
2202
- // treat `fixed` as authoritative — emitting it without operator
2203
- // attestation is a downstream-misleading bug. Now the OpenVEX
2204
- // statement says `affected` (with action_statement pointing to the
2205
- // remediation, which may itself be the vendor live-patch route) unless
2206
- // the operator declared `vex_status: fixed` on the matched CVE.
2329
+ // disposition. Treating it as `status: fixed` would make OpenVEX
2330
+ // statements claim resolution the operator hadn't attested to. VEX
2331
+ // consumers downstream of CISA / SBOM / supply-chain pipelines treat
2332
+ // `fixed` as authoritative — emitting it without operator attestation
2333
+ // is a downstream-misleading bug. The OpenVEX statement says
2334
+ // `affected` (with action_statement pointing to the remediation,
2335
+ // which may itself be the vendor live-patch route) unless the
2336
+ // operator declared `vex_status: fixed` on the matched CVE.
2207
2337
  const cveStatements = analyze.matched_cves.map(c => {
2208
2338
  const stmt = {
2209
2339
  vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
@@ -2300,11 +2430,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2300
2430
  return { format: 'markdown', body: lines.join('\n') };
2301
2431
  }
2302
2432
 
2303
- // F16: pre-fix the fallback leaked raw analyze + validate internals
2304
- // (matched CVEs, framework gaps, residual-risk statements) under an
2305
- // arbitrary "format" name. Operators piping output to logging or
2306
- // third-party tooling could leak finding details just by typo'ing the
2307
- // format flag. Return the shape advertisement only.
2433
+ // The fallback must NOT leak raw analyze + validate internals (matched
2434
+ // CVEs, framework gaps, residual-risk statements) under an arbitrary
2435
+ // "format" name operators piping output to logging or third-party
2436
+ // tooling could leak finding details just by typo'ing the format flag.
2437
+ // Return the shape advertisement only.
2308
2438
  return {
2309
2439
  format,
2310
2440
  note: 'Unknown format',
@@ -2329,11 +2459,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2329
2459
  function normalizeSubmission(submission, playbook) {
2330
2460
  if (!submission || typeof submission !== "object") return submission || {};
2331
2461
 
2332
- // F15: signal_overrides must be a plain object. Pre-fix, a non-object
2333
- // value (string "foo", array [...]) was spread into out.signal_overrides
2334
- // via `{ ...(submission.signal_overrides || {}) }`. Spreading a string
2335
- // splatted it into { '0': 'f', '1': 'o', '2': 'o' }, which then
2336
- // confused detect()'s indicator-id lookup. Strip and log instead.
2462
+ // signal_overrides must be a plain object. Without this guard, a
2463
+ // non-object value (string "foo", array [...]) is spread into
2464
+ // out.signal_overrides via `{ ...(submission.signal_overrides || {}) }`
2465
+ // spreading a string splatters it into { '0': 'f', '1': 'o', '2': 'o' },
2466
+ // which confuses detect()'s indicator-id lookup. Strip and log instead.
2337
2467
  if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
2338
2468
  && (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
2339
2469
  if (!submission._runErrors) submission._runErrors = [];
@@ -2366,13 +2496,13 @@ function normalizeSubmission(submission, playbook) {
2366
2496
  signals: { ...(submission.signals || {}) },
2367
2497
  precondition_checks: { ...(submission.precondition_checks || {}) },
2368
2498
  _original_shape: 'flat (v0.11.0)',
2369
- // BB P1-4: normalizeSubmission pushes structured errors (e.g.
2370
- // signal_overrides_invalid) onto submission._runErrors above. If the
2371
- // submission is flat, the fresh `out` literal built here loses that
2372
- // accumulator unless we forward it. run()'s harvest at the entry to
2373
- // detect/analyze reads agentSubmission._runErrors without this carry,
2374
- // flat submissions with invalid signal_overrides silently lost the
2375
- // v0.12.19 U REG-1 contract (errors never reached analyze.runtime_errors).
2499
+ // normalizeSubmission pushes structured errors (e.g.
2500
+ // signal_overrides_invalid) onto submission._runErrors above. For flat
2501
+ // submissions the fresh `out` literal built here loses that accumulator
2502
+ // unless we forward it; run()'s harvest at the entry to detect/analyze
2503
+ // reads agentSubmission._runErrors, so without the carry, flat
2504
+ // submissions with invalid signal_overrides drop the errors before
2505
+ // they can reach analyze.runtime_errors.
2376
2506
  ...(Array.isArray(submission._runErrors) && submission._runErrors.length
2377
2507
  ? { _runErrors: submission._runErrors.slice() }
2378
2508
  : {}),
@@ -2394,7 +2524,7 @@ function normalizeSubmission(submission, playbook) {
2394
2524
  // detect can emit `from_observation` on each indicator result. Diagnostic
2395
2525
  // value for operators chasing "which observation drove this verdict".
2396
2526
  //
2397
- // E9: when two observations target the same indicator id, last-write-wins
2527
+ // When two observations target the same indicator id, last-write-wins
2398
2528
  // silently. Track discards in _signal_origins_collisions so analyze can
2399
2529
  // surface analyze.signal_origins_with_collisions for batch evidence runs.
2400
2530
  out._signal_origins = out._signal_origins || {};
@@ -2476,7 +2606,7 @@ function autoDetectPreconditions(submission, playbook) {
2476
2606
  }
2477
2607
 
2478
2608
  function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2479
- // F7: catalog corruption surfaced at module-load now blocks runs cleanly.
2609
+ // Catalog corruption surfaced at module-load blocks runs cleanly.
2480
2610
  if (_xrefLoadError) {
2481
2611
  return {
2482
2612
  ok: false,
@@ -2490,7 +2620,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2490
2620
  try {
2491
2621
  playbook = loadPlaybook(playbookId);
2492
2622
  } catch (e) {
2493
- // F20: loadPlaybook failure → structured error (not crash).
2623
+ // loadPlaybook failure → structured error (not crash).
2494
2624
  return {
2495
2625
  ok: false,
2496
2626
  blocked_by: 'playbook_not_found',
@@ -2499,9 +2629,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2499
2629
  };
2500
2630
  }
2501
2631
 
2502
- // F8: validate directiveId before any phase runs. Unknown id used to throw
2503
- // inside analyze()/findDirective() uncaught, surfacing as a 500-style stack
2504
- // trace. Now returns a clean structured error with the valid directive list.
2632
+ // Validate directiveId before any phase runs. An unknown id would
2633
+ // otherwise throw inside analyze() / findDirective() uncaught, surfacing
2634
+ // as a 500-style stack trace; instead return a clean structured error
2635
+ // with the valid directive list.
2505
2636
  const validDirectives = (playbook.directives || []).map(d => d.id);
2506
2637
  if (!validDirectives.includes(directiveId)) {
2507
2638
  return {
@@ -2518,12 +2649,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2518
2649
  // / the host platform matches — the runner can answer those itself rather
2519
2650
  // than blocking on AI declaration.
2520
2651
  agentSubmission = normalizeSubmission(agentSubmission, playbook);
2521
- // F22: capture pre-autoDetect submission preconditions so we report
2652
+ // Capture pre-autoDetect submission preconditions so we report
2522
2653
  // user-declared provenance, not engine-auto-resolved values.
2523
2654
  const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
2524
2655
  agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
2525
2656
 
2526
- // F22: precondition_checks merge order is submission → runOpts (runOpts
2657
+ // precondition_checks merge order is submission → runOpts (runOpts
2527
2658
  // wins on collision). This is intentional: runOpts represents the most
2528
2659
  // recent caller intent (CLI flags / programmatic injection from a host
2529
2660
  // process), whereas submission was captured earlier during evidence
@@ -2551,38 +2682,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2551
2682
  // Cross-process mutex lock for this run. preflight verified no other lock
2552
2683
  // exists; we acquire ours and release in the finally block.
2553
2684
  const lockPath = acquireLock(playbookId);
2554
- // E12: parse the playbook once at run() entry and thread the parsed object
2555
- // through each phase via runOpts._playbookCache. Each phase otherwise calls
2556
- // loadPlaybook() independently; for a single run that's seven reads + parses
2557
- // of the same file. Cached version saves the redundant I/O + JSON parses.
2685
+ // Parse the playbook once at run() entry and thread the parsed object
2686
+ // through each phase via runOpts._playbookCache. Each phase otherwise
2687
+ // calls loadPlaybook() independently; for a single run that's seven
2688
+ // reads + parses of the same file. Caching saves the redundant I/O +
2689
+ // JSON parses.
2558
2690
  //
2559
- // F2/F9: session_id generated ONCE here, threaded into close() via
2560
- // cachedRunOpts.session_id. Pre-fix close() generated its own session_id
2561
- // independently, so CSAF tracking.id / OpenVEX @id / product PURLs all
2562
- // diverged from the run()-returned session_id and the on-disk attestation
2563
- // file name. Operators correlating attestation files to embedded bundle
2564
- // URNs got mismatched ids.
2691
+ // session_id is generated ONCE here and threaded into close() via
2692
+ // cachedRunOpts.session_id so CSAF tracking.id / OpenVEX @id / product
2693
+ // PURLs / on-disk attestation filenames all share one identifier.
2694
+ // Without the single-source-of-truth, close() would mint its own id
2695
+ // and operators correlating attestation files to embedded bundle URNs
2696
+ // would see mismatches.
2565
2697
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
2566
2698
  const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
2567
- // E3: run-time error accumulator for evalCondition regex failures and other
2699
+ // Run-time error accumulator for evalCondition regex failures and other
2568
2700
  // non-fatal anomalies surfaced into analyze.runtime_errors[].
2569
2701
  const runErrors = [];
2570
2702
  cachedRunOpts._runErrors = runErrors;
2571
- // U REG-1: normalizeSubmission may push structured errors (e.g.
2572
- // signal_overrides_invalid) onto submission._runErrors. Pre-fix these were
2573
- // stranded — they never reached the run-level accumulator that analyze()
2574
- // slices into runtime_errors[], so F20's "analyze surfaces all runtime
2575
- // errors" contract was silently broken. Splice the pre-run errors into
2576
- // the run-level accumulator and strip the field off the submission so it
2577
- // doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
2578
- // submission and a non-deterministic _runErrors would change it).
2703
+ // normalizeSubmission may push structured errors (e.g.
2704
+ // signal_overrides_invalid) onto submission._runErrors. Splice them
2705
+ // into the run-level accumulator so analyze.runtime_errors[] surfaces
2706
+ // them, and strip the field off the submission so it doesn't pollute
2707
+ // the evidence_hash digest (the hash canonicalizes the submission and
2708
+ // a non-deterministic _runErrors would change it).
2579
2709
  if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
2580
2710
  runErrors.push(...agentSubmission._runErrors);
2581
2711
  }
2582
2712
  if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
2583
2713
  delete agentSubmission._runErrors;
2584
2714
  }
2585
- // E6: phases the runner should SKIP execution for, based on skip_phase
2715
+ // Phases the runner should SKIP execution for, based on skip_phase
2586
2716
  // preconditions surfaced in preflight.issues.
2587
2717
  const skipPhases = new Set();
2588
2718
  for (const issue of (pre.issues || [])) {
@@ -2624,7 +2754,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2624
2754
  phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
2625
2755
  phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
2626
2756
 
2627
- // E3: analyze() already sliced runOpts._runErrors into
2757
+ // analyze() already sliced runOpts._runErrors into
2628
2758
  // phases.analyze.runtime_errors at return time. Validate + close may
2629
2759
  // have pushed additional regex errors AFTER analyze returned; surface
2630
2760
  // those onto phases.analyze.runtime_errors so the field reflects every
@@ -2638,14 +2768,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2638
2768
  }
2639
2769
  }
2640
2770
 
2641
- // F1: evidence_hash binds the operator's submission to the verdict.
2642
- // Pre-fix the hash only covered { playbook, directive, cves, rwep,
2643
- // classification }two operators submitting completely different
2644
- // evidence that happened to produce the same classification got the
2645
- // same evidence_hash, breaking the contract that the hash uniquely
2646
- // identifies a run. Now the hash includes a canonicalized SHA-256 over
2647
- // the submission (observations, signal_overrides, signals) with sorted
2648
- // keys recursively. `captured_at` and other timestamp-like fields are
2771
+ // evidence_hash binds the operator's submission to the verdict. The
2772
+ // hash must include the canonicalized submission (observations,
2773
+ // signal_overrides, signals)keying it on only { playbook, directive,
2774
+ // cves, rwep, classification } would let two operators with completely
2775
+ // different evidence collide on the same hash whenever their
2776
+ // classifications match. Use SHA-256 over the recursively sorted
2777
+ // submission. `captured_at` and other timestamp-like fields are
2649
2778
  // INTENTIONALLY excluded so that re-running with the same submission
2650
2779
  // produces the same hash — `reattest` relies on this to detect drift
2651
2780
  // (different submission → different hash → drift exists).
@@ -2670,7 +2799,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2670
2799
  evidence_hash: evidenceHash,
2671
2800
  submission_digest: submissionDigest,
2672
2801
  preflight_issues: pre.issues,
2673
- // F22: source provenance for precondition_checks. Shape:
2802
+ // Source provenance for precondition_checks. Shape:
2674
2803
  // { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
2675
2804
  precondition_check_source: pcSource,
2676
2805
  phases
@@ -2684,7 +2813,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2684
2813
  // --- helpers ---
2685
2814
 
2686
2815
  /**
2687
- * F1: deterministic JSON stringification with recursively sorted keys.
2816
+ * Deterministic JSON stringification with recursively sorted keys.
2688
2817
  * Without sorted keys two semantically identical submissions ({a:1, b:2}
2689
2818
  * vs {b:2, a:1}) would hash to different digests, breaking reattest's
2690
2819
  * "same submission → same hash" contract. Arrays preserve order
@@ -2700,7 +2829,7 @@ function canonicalStringify(v) {
2700
2829
  }
2701
2830
 
2702
2831
  /**
2703
- * F1: pick the operator-meaningful fields out of the normalized submission
2832
+ * Pick the operator-meaningful fields out of the normalized submission
2704
2833
  * for hashing. captured_at, _signal_origins, _signal_origins_collisions,
2705
2834
  * and _original_shape are intentionally excluded — they're either
2706
2835
  * timestamps (would break "same submission → same hash") or runner-internal
@@ -2807,7 +2936,7 @@ function evalCondition(expr, ctx, playbook) {
2807
2936
  if (m) {
2808
2937
  const val = resolvePath(ctx, m[1]);
2809
2938
  if (typeof val !== 'string') return false;
2810
- // E3: an operator-supplied or playbook-supplied regex with a syntax bug
2939
+ // An operator-supplied or playbook-supplied regex with a syntax bug
2811
2940
  // (or pathological backtracking) must NOT crash the engine mid-analyze.
2812
2941
  // Catch construction + test exceptions, return false, and push a
2813
2942
  // structured _regex_eval_error into ctx._runErrors (when present) so
@@ -2886,12 +3015,11 @@ function stripOuterParens(expr) {
2886
3015
  * submits clock_started_at_<event> ISO strings as it progresses through
2887
3016
  * incident-response milestones.
2888
3017
  *
2889
- * E7: per AGENTS.md Phase 7, the legal contract is that the clock starts
3018
+ * Per AGENTS.md Phase 7, the legal contract is that the clock starts
2890
3019
  * from OPERATOR AWARENESS — not from the moment the engine emits a
2891
- * `detected` classification. Pre-fix, this auto-stamped Date.now() on
2892
- * detect_confirmed whenever the engine classified as detected, which is
2893
- * incorrect: the operator may not have seen the result yet. The corrected
2894
- * semantics:
3020
+ * `detected` classification. Auto-stamping Date.now() on detect_confirmed
3021
+ * whenever the engine classifies as detected would be incorrect: the
3022
+ * operator may not have seen the result yet. Semantics:
2895
3023
  *
2896
3024
  * - If the agent explicitly submits clock_started_at_<event>: use it.
2897
3025
  * - Otherwise, for 'detect_confirmed' with classification='detected':
@@ -2994,6 +3122,10 @@ module.exports = {
2994
3122
  vexFilterFromDoc,
2995
3123
  normalizeSubmission,
2996
3124
  autoDetectPreconditions,
3125
+ // Exported so library-side direct callers (the fallback path the CLI
3126
+ // guard cannot reach) can be exercised without spawning a CLI
3127
+ // subprocess.
3128
+ sanitizeOperatorText,
2997
3129
  // internal helpers exposed for tests
2998
3130
  _resolvedPhase: resolvedPhase,
2999
3131
  _deepMerge: deepMerge,