@blamejs/exceptd-skills 0.12.22 → 0.12.24

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 (47) hide show
  1. package/AGENTS.md +18 -12
  2. package/ARCHITECTURE.md +2 -2
  3. package/CHANGELOG.md +152 -2
  4. package/CONTEXT.md +126 -69
  5. package/README.md +21 -8
  6. package/bin/exceptd.js +972 -464
  7. package/data/_indexes/_meta.json +3 -3
  8. package/data/_indexes/stale-content.json +10 -3
  9. package/data/playbooks/ai-api.json +1 -1
  10. package/data/playbooks/containers.json +1 -1
  11. package/data/playbooks/cred-stores.json +1 -1
  12. package/data/playbooks/crypto-codebase.json +1 -1
  13. package/data/playbooks/crypto.json +1 -1
  14. package/data/playbooks/framework.json +1 -1
  15. package/data/playbooks/hardening.json +1 -1
  16. package/data/playbooks/kernel.json +1 -1
  17. package/data/playbooks/library-author.json +1 -1
  18. package/data/playbooks/mcp.json +1 -1
  19. package/data/playbooks/runtime.json +1 -1
  20. package/data/playbooks/sbom.json +1 -1
  21. package/data/playbooks/secrets.json +39 -1
  22. package/lib/auto-discovery.js +28 -4
  23. package/lib/cross-ref-api.js +12 -11
  24. package/lib/cve-curation.js +18 -19
  25. package/lib/exit-codes.js +72 -0
  26. package/lib/flag-suggest.js +130 -0
  27. package/lib/id-validation.js +95 -0
  28. package/lib/lint-skills.js +73 -6
  29. package/lib/playbook-runner.js +617 -343
  30. package/lib/prefetch.js +134 -21
  31. package/lib/refresh-external.js +205 -26
  32. package/lib/refresh-network.js +64 -16
  33. package/lib/schemas/cve-catalog.schema.json +7 -1
  34. package/lib/schemas/playbook.schema.json +51 -0
  35. package/lib/scoring.js +49 -7
  36. package/lib/sign.js +10 -11
  37. package/lib/source-osv.js +7 -7
  38. package/lib/upstream-check-cli.js +16 -1
  39. package/lib/upstream-check.js +9 -0
  40. package/lib/validate-catalog-meta.js +1 -1
  41. package/lib/validate-cve-catalog.js +1 -1
  42. package/lib/verify.js +56 -30
  43. package/manifest.json +40 -40
  44. package/package.json +8 -2
  45. package/sbom.cdx.json +6 -6
  46. package/scripts/check-test-coverage.js +67 -0
  47. package/scripts/verify-shipped-tarball.js +27 -18
@@ -47,8 +47,9 @@ const fs = require('fs');
47
47
  const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
+ const scoring = require('./scoring');
50
51
 
51
- // F7: cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
52
+ // cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
52
53
  // JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
53
54
  // failure, returns an empty stub, and accumulates the error in
54
55
  // getLoadErrors(). run() probes for accumulated load errors and returns
@@ -89,6 +90,61 @@ const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data',
89
90
  // platform integration, not the runner.
90
91
  const _activeRuns = new Set();
91
92
 
93
+ // Bounded push into a runtime_errors array with per-kind caps, optional
94
+ // per-kind dedupe, and a total cap. A long-running detect/analyze loop that
95
+ // rejects a malformed catalog entry on every iteration would otherwise let
96
+ // runtime_errors grow unbounded and balloon the bundle output. When the cap
97
+ // fires the helper records a `_truncated` sentinel so downstream consumers
98
+ // see the drop without needing to compare cardinalities.
99
+ //
100
+ // opts.cap per-kind cap (default 100)
101
+ // opts.totalCap total array cap (default 1000)
102
+ // opts.dedupeKey optional fn(entry) returning a string key. When supplied,
103
+ // a push with the same (kind, dedupeKey) tuple is skipped.
104
+ //
105
+ // Returns true if the entry was pushed, false otherwise (capped or deduped).
106
+ function pushRunError(arr, entry, opts) {
107
+ if (!Array.isArray(arr) || !entry || typeof entry !== 'object') return false;
108
+ opts = opts || {};
109
+ const cap = typeof opts.cap === 'number' ? opts.cap : 100;
110
+ const totalCap = typeof opts.totalCap === 'number' ? opts.totalCap : 1000;
111
+ const kind = entry.kind;
112
+ if (typeof opts.dedupeKey === 'function' && kind) {
113
+ const dk = opts.dedupeKey(entry);
114
+ if (arr.some(e => e && e.kind === kind && opts.dedupeKey(e) === dk)) {
115
+ return false;
116
+ }
117
+ }
118
+ const total = arr.length;
119
+ const kindCount = kind ? arr.filter(e => e && e.kind === kind).length : 0;
120
+ const overTotal = total >= totalCap;
121
+ const overKind = kind && kindCount >= cap;
122
+ if (overTotal || overKind) {
123
+ const reason = overKind ? 'per-kind-cap' : 'total-cap';
124
+ const existing = arr.find(e => e && e.kind === '_truncated' && e.truncated_kind === (kind || null) && e.reason === reason);
125
+ if (existing) {
126
+ existing.dropped = (existing.dropped || 0) + 1;
127
+ } else {
128
+ arr.push({ kind: '_truncated', truncated_kind: kind || null, dropped: 1, reason });
129
+ }
130
+ return false;
131
+ }
132
+ arr.push(entry);
133
+ return true;
134
+ }
135
+
136
+ // Unwrap a legacy `{ _regex_eval_error: { source, expr, message } }` record
137
+ // into the flat fields pushRunError dedupes on. Used by evalCondition()'s
138
+ // regex-failure path so per-(source, expr) duplicates collapse to one entry
139
+ // plus a `_truncated` sentinel when the cap fires.
140
+ function _regexErrorPayload(rec) {
141
+ if (rec && typeof rec === 'object' && rec._regex_eval_error) {
142
+ const { source, expr, message } = rec._regex_eval_error;
143
+ return { source, expr, message, _regex_eval_error: rec._regex_eval_error };
144
+ }
145
+ return { _regex_eval_error: rec };
146
+ }
147
+
92
148
  // --- catalog access ---
93
149
 
94
150
  function listPlaybooks() {
@@ -104,7 +160,7 @@ function loadPlaybook(playbookId) {
104
160
  return JSON.parse(fs.readFileSync(p, 'utf8'));
105
161
  }
106
162
 
107
- // E12: per-run playbook cache. Each phase function reads runOpts._playbookCache
163
+ // Per-run playbook cache. Each phase function reads runOpts._playbookCache
108
164
  // before falling back to loadPlaybook(). run() sets _playbookCache once at
109
165
  // entry so seven phases share one disk read + JSON parse instead of seven.
110
166
 
@@ -149,16 +205,17 @@ function deepMerge(a, b) {
149
205
  * 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
150
206
  * AND with the filesystem lockfile dir blocks the run.
151
207
  *
152
- * E5: when runOpts.strictPreconditions === true, warn-level outcomes
208
+ * When runOpts.strictPreconditions === true, warn-level outcomes
153
209
  * (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
210
+ * skip_phase) are ESCALATED to halts. The function returns ok:false
211
+ * with blocked_by='precondition' and an issues array containing
156
212
  * precondition_halt entries. Callers wanting "CI gate: any unverified
157
213
  * precondition is a failure" pass strictPreconditions=true.
158
214
  *
159
- * E6: when a precondition with on_fail='skip_phase' fails, the issue carries
215
+ * When a precondition with on_fail='skip_phase' fails, the issue carries
160
216
  * skip_phase: 'detect' (default) so run() can route to a skipped-phase
161
- * placeholder rather than executing detect against a missing prerequisite.
217
+ * placeholder rather than executing detect against a missing
218
+ * prerequisite.
162
219
  */
163
220
  function preflight(playbook, runOpts = {}) {
164
221
  const issues = [];
@@ -185,7 +242,7 @@ function preflight(playbook, runOpts = {}) {
185
242
  if (submitted === undefined) {
186
243
  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
244
  if (strict) {
188
- // E5: strictPreconditions promotes unverified to halt regardless of
245
+ // strictPreconditions promotes unverified to halt regardless of
189
246
  // declared on_fail.
190
247
  issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
191
248
  return {
@@ -213,7 +270,7 @@ function preflight(playbook, runOpts = {}) {
213
270
  return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
214
271
  }
215
272
  if (strict) {
216
- // E5: warn-level + skip_phase outcomes escalate to halt under strict.
273
+ // Warn-level + skip_phase outcomes escalate to halt under strict.
217
274
  issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
218
275
  return {
219
276
  ok: false,
@@ -223,7 +280,7 @@ function preflight(playbook, runOpts = {}) {
223
280
  };
224
281
  }
225
282
  if (pc.on_fail === 'skip_phase') {
226
- // E6: emit a skip_phase field so run() can route to a skipped-phase
283
+ // Emit a skip_phase field so run() can route to a skipped-phase
227
284
  // placeholder. Default target phase is 'detect' (the most common
228
285
  // skip target — preconditions typically gate host-side detection).
229
286
  // Playbooks may override via pc.skip_phase.
@@ -266,11 +323,11 @@ function preflight(playbook, runOpts = {}) {
266
323
  return { ok: true, issues };
267
324
  }
268
325
 
269
- // F28: lockDir lives at a stable global path so two CLI invocations from
326
+ // lockDir lives at a stable global path so two CLI invocations from
270
327
  // 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
328
+ // mutex enforcement. A process.cwd()-relative dir would let invocations
329
+ // from /tmp and from /home/user/project simultaneously each see an empty
330
+ // locks dir and both run unchallenged. The path
274
331
  // keys on os.platform() so Windows/macOS/Linux locks live under separate
275
332
  // directories (avoids cross-platform stale-PID confusion when a host is
276
333
  // shared across OSes via networked FS). Override via EXCEPTD_LOCK_DIR for
@@ -287,13 +344,14 @@ function lockFilePath(playbookId) {
287
344
  catch { return null; }
288
345
  }
289
346
 
290
- // PP P1-1: same-PID stale-lockfile reclaim threshold. A same-process orphan
291
- // (e.g. an earlier run() that crashed without unlinking, or a try/catch that
347
+ // Same-PID stale-lockfile reclaim threshold. A same-process orphan (e.g.
348
+ // an earlier run() that crashed without unlinking, or a try/catch that
292
349
  // swallowed the release) older than this is presumed dead and reclaimed.
293
- // 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough that
294
- // no legitimate playbook hold reaches it (govern/look/run phases complete
295
- // well inside one second per playbook), short enough that a wedged process
296
- // recovers within one CI step rather than the rest of its lifetime.
350
+ // 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
351
+ // that no legitimate playbook hold reaches it (govern/look/run phases
352
+ // complete well inside one second per playbook), short enough that a
353
+ // wedged process recovers within one CI step rather than the rest of its
354
+ // lifetime.
297
355
  const STALE_LOCK_MS = 30_000;
298
356
 
299
357
  function acquireLock(playbookId) {
@@ -308,16 +366,14 @@ function acquireLock(playbookId) {
308
366
  writePayload();
309
367
  return p;
310
368
  } catch (e) {
311
- // DD P1-3: stale-PID reclaim. Pre-fix the EEXIST path returned null
312
- // and callers proceeded UNLOCKED a process that crashed mid-run
313
- // left its lockfile behind and every subsequent invocation silently
314
- // ran without mutex protection. Mirror withCatalogLock's pattern:
315
- // parse the recorded pid, probe with `process.kill(pid, 0)`. ESRCH
316
- // means the holder is dead unlink and retry once. EPERM (alive,
317
- // different user) or any other condition: leave the lock alone and
318
- // return null with a diagnostic so the caller knows acquisition
319
- // failed because the lock is genuinely held (not because the FS is
320
- // broken or the playbook id is malformed).
369
+ // Stale-PID reclaim. Without it, a process that crashed mid-run
370
+ // leaves its lockfile behind and every subsequent invocation runs
371
+ // UNLOCKED. Mirror withCatalogLock's pattern: parse the recorded pid,
372
+ // probe with `process.kill(pid, 0)`. ESRCH means the holder is dead —
373
+ // unlink and retry once. EPERM (alive, different user) or any other
374
+ // condition: leave the lock alone and return null with a diagnostic so
375
+ // the caller knows acquisition failed because the lock is genuinely
376
+ // held (not because the FS is broken or the playbook id is malformed).
321
377
  if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
322
378
  try {
323
379
  const raw = fs.readFileSync(p, 'utf8');
@@ -331,13 +387,13 @@ function acquireLock(playbookId) {
331
387
  try { fs.unlinkSync(p); } catch {}
332
388
  try { writePayload(); return p; } catch { /* fall through */ }
333
389
  }
334
- // PP P1-1: same-PID stale-lockfile reclaim. If the recorded pid is
335
- // ours, the only way to escape an orphaned same-process lockfile is
336
- // by mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
337
- // (e.g. nested run() within one process) must still return null so
338
- // the caller knows the lock is held. A fresh same-PID lockfile is
339
- // reentrancy; one older than STALE_LOCK_MS is an orphan from a
340
- // crashed prior hold (or a try/catch that swallowed the release)
390
+ // Same-PID stale-lockfile reclaim. If the recorded pid is ours,
391
+ // the only way to escape an orphaned same-process lockfile is by
392
+ // mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
393
+ // (e.g. nested run() within one process) must still return null
394
+ // so the caller knows the lock is held. A fresh same-PID lockfile
395
+ // is reentrancy; one older than STALE_LOCK_MS is an orphan from
396
+ // a crashed prior hold (or a try/catch that swallowed the release)
341
397
  // and must be reclaimed — otherwise the process can never acquire
342
398
  // this lock again for the rest of its lifetime.
343
399
  if (Number.isInteger(pid) && pid === process.pid) {
@@ -359,9 +415,9 @@ function acquireLock(playbookId) {
359
415
  }
360
416
  }
361
417
 
362
- // DD P1-3: callers needing to distinguish "couldn't acquire because the
363
- // lock is genuinely held by a live process" from "couldn't acquire
364
- // because of an unexpected error" can use this thin diagnostic wrapper.
418
+ // Callers needing to distinguish "couldn't acquire because the lock is
419
+ // genuinely held by a live process" from "couldn't acquire because of an
420
+ // unexpected error" can use this thin diagnostic wrapper.
365
421
  // Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
366
422
  // The bare `acquireLock` keeps its historical null-on-failure contract.
367
423
  function acquireLockDiagnostic(playbookId) {
@@ -394,10 +450,10 @@ function acquireLockDiagnostic(playbookId) {
394
450
  return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
395
451
  }
396
452
  }
397
- // PP P1-1: same-PID stale-lockfile reclaim (diagnostic variant). Same
453
+ // Same-PID stale-lockfile reclaim (diagnostic variant). Same
398
454
  // semantics as in acquireLock: a same-process lockfile older than
399
- // STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one is
400
- // legitimate reentrancy and stays held.
455
+ // STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
456
+ // is legitimate reentrancy and stays held.
401
457
  if (Number.isInteger(pid) && pid === process.pid) {
402
458
  let mtimeMs = null;
403
459
  try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
@@ -441,7 +497,7 @@ function pidAlive(pid) {
441
497
  function govern(playbookId, directiveId, runOpts = {}) {
442
498
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
443
499
  const g = resolvedPhase(playbook, directiveId, 'govern');
444
- // F12: sort jurisdiction obligations by window_hours ascending so the
500
+ // Sort jurisdiction obligations by window_hours ascending so the
445
501
  // tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
446
502
  // first. Operators reading the govern output for ack-time briefing need
447
503
  // the most urgent clock at the top of the list.
@@ -557,7 +613,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
557
613
  return null; // truly unknown — fall through
558
614
  };
559
615
 
560
- // E1: per-indicator FP-check attestation map. Operators submit
616
+ // Per-indicator FP-check attestation map. Operators submit
561
617
  // signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
562
618
  // to declare which named false_positive_checks_required[] entries on the
563
619
  // indicator have been satisfied. An unverified FP check downgrades the
@@ -572,12 +628,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
572
628
  let fpChecksUnsatisfied = null;
573
629
  if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
574
630
  verdict = override;
575
- // E1: gate 'hit' verdict on per-indicator false_positive_checks_required
631
+ // Gate 'hit' verdict on per-indicator false_positive_checks_required
576
632
  // satisfaction. The FP-check attestation arrives as a sibling key
577
633
  // '<id>__fp_checks' in signal_overrides; default behavior (no
578
634
  // attestation) treats every required FP check as UNSATISFIED.
579
635
  if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
580
- // BB P2-4: a hostile or buggy attestation may be a Proxy whose property
636
+ // A hostile or buggy attestation may be a Proxy whose property
581
637
  // accessors throw. The filter below reads `att[fpName]` for each
582
638
  // required check; an exception inside the read would crash detect()
583
639
  // and abort the entire run. Wrap the FP-check evaluation in a
@@ -586,13 +642,14 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
586
642
  // read) and surface a runtime_error so the operator sees why.
587
643
  try {
588
644
  const attestation = overrides[`${ind.id}__fp_checks`];
589
- // S P1-A: arrays satisfy `typeof === 'object'` but are NOT a valid
645
+ // Arrays satisfy `typeof === 'object'` but are NOT a valid
590
646
  // attestation map. A submission like
591
647
  // signal_overrides: { sig__fp_checks: [true, true] }
592
- // would previously have its truthy entries matched via the index
648
+ // would otherwise have its truthy entries matched via the index
593
649
  // fallback (att['0'] === true), silently bypassing every FP-check
594
- // requirement. Reject arrays explicitly so they fall through to the
595
- // empty-attestation branch (every required check unsatisfied).
650
+ // requirement. Reject arrays explicitly so they fall through to
651
+ // the empty-attestation branch (every required check
652
+ // unsatisfied).
596
653
  const safeAtt = Array.isArray(attestation) ? null : attestation;
597
654
  const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
598
655
  const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
@@ -617,11 +674,11 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
617
674
  verdict = 'inconclusive';
618
675
  fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
619
676
  if (runOpts && Array.isArray(runOpts._runErrors)) {
620
- runOpts._runErrors.push({
677
+ pushRunError(runOpts._runErrors, {
621
678
  kind: 'fp_attestation_threw',
622
679
  indicator_id: ind.id,
623
680
  message: (e && e.message) ? String(e.message) : String(e),
624
- });
681
+ }, { dedupeKey: e => e.indicator_id || '' });
625
682
  }
626
683
  }
627
684
  }
@@ -632,9 +689,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
632
689
  // host AI is responsible for that). With NO captured artifacts, this is
633
690
  // a clean empty submission — emit 'miss' so the run can reach
634
691
  // classification:'not_detected' rather than getting stuck inconclusive.
635
- // E2: pre-fix both arms emitted 'inconclusive', so a clean empty run
636
- // could never reach not_detected and theater_verdict stayed
637
- // 'pending_agent_run' forever.
692
+ // A clean empty run with no captured artifacts must emit 'miss' so
693
+ // classification can reach 'not_detected'; otherwise theater_verdict
694
+ // stays 'pending_agent_run' indefinitely.
638
695
  const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
639
696
  verdict = anyCaptured ? 'inconclusive' : 'miss';
640
697
  }
@@ -664,9 +721,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
664
721
  // confirmed they're all benign" without this override.
665
722
  const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
666
723
  const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
667
- // BB P2-1: any override that's a non-empty string but NOT in the allowlist
668
- // (e.g. 'present', 'unknown', '', ' detected ', 'Detected') must surface
669
- // as a runtime_error rather than silently falling through to engine-computed
724
+ // Any override that's a non-empty string but NOT in the allowlist (e.g.
725
+ // 'present', 'unknown', '', ' detected ', 'Detected') surfaces as a
726
+ // runtime_error rather than silently falling through to engine-computed
670
727
  // classification. Operators submitting case variants / whitespace-padded
671
728
  // strings deserve a clear diagnostic, not a quiet downgrade. Treat the
672
729
  // override as absent for classification purposes once recorded.
@@ -674,27 +731,27 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
674
731
  const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
675
732
  if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
676
733
  if (runOpts && Array.isArray(runOpts._runErrors)) {
677
- runOpts._runErrors.push({
734
+ pushRunError(runOpts._runErrors, {
678
735
  kind: 'classification_override_invalid',
679
736
  supplied: rawOverride,
680
737
  allowed: ['detected', 'inconclusive', 'not_detected', 'clean'],
681
738
  reason: 'signals.detection_classification must be one of the allowlist values exactly (case-sensitive, no surrounding whitespace). Override ignored; engine-computed classification used.',
682
- });
739
+ }, { dedupeKey: e => String(e.supplied) });
683
740
  }
684
741
  }
685
742
  const override = overrideIsInAllowlist ? rawOverride : undefined;
686
743
 
687
- // BB P1-1 / BB P1-2: extend the v0.12.19 S P1-B gate to refuse ALL
688
- // classification overrides (`detected`, `clean`, `not_detected`) when any
689
- // indicator was FP-downgraded. A submission that maps to `'not_detected'`
690
- // (either by literal `not_detected` OR by `'clean'`, which v0.12.19 mapped
691
- // to `'not_detected'` at this site) MUST NOT hide a `verdict: 'hit'`
692
- // indicator whose `false_positive_checks_required[]` were unattested
693
- // that's a strictly worse false-negative outcome than allowing 'detected'
694
- // through. Substitute 'inconclusive' and emit a runtime_error.
695
- // BB P2-2: record indicator IDs and an unsatisfied-checks count ONLY —
696
- // never the literal FP-check check-name strings (those are an attestation-
697
- // bypass hint for a hostile agent reading the runtime_errors).
744
+ // Refuse ALL classification overrides (`detected`, `clean`,
745
+ // `not_detected`) when any indicator was FP-downgraded. A submission
746
+ // that maps to `'not_detected'` (either literally or via `'clean'`,
747
+ // which maps to `'not_detected'` at this site) MUST NOT hide a
748
+ // `verdict: 'hit'` indicator whose `false_positive_checks_required[]`
749
+ // were unattested that's a strictly worse false-negative outcome than
750
+ // allowing 'detected' through. Substitute 'inconclusive' and emit a
751
+ // runtime_error.
752
+ // Record indicator IDs and an unsatisfied-checks count ONLY — never the
753
+ // literal FP-check check-name strings (those are an attestation-bypass
754
+ // hint for a hostile agent reading the runtime_errors).
698
755
  const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
699
756
 
700
757
  let classification;
@@ -705,7 +762,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
705
762
  const attempted = override; // record what the operator submitted, not the mapped form
706
763
  classification = substituted;
707
764
  if (runOpts && Array.isArray(runOpts._runErrors)) {
708
- runOpts._runErrors.push({
765
+ pushRunError(runOpts._runErrors, {
709
766
  kind: 'classification_override_blocked',
710
767
  attempted,
711
768
  substituted,
@@ -713,7 +770,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
713
770
  indicators_with_unsatisfied_fp_checks: indicatorResults
714
771
  .filter(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0)
715
772
  .map(r => ({ id: r.id, fp_checks_unsatisfied_count: r.fp_checks_unsatisfied.length })),
716
- });
773
+ }, { dedupeKey: e => String(e.attempted) });
717
774
  }
718
775
  }
719
776
  } else if (hasDeterministicHit || hasHighConfHit) {
@@ -753,7 +810,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
753
810
  indicators_evaluated_count: indicatorResults.length,
754
811
  classification_override_applied: override ? (override === 'clean' ? 'not_detected' : override) : null,
755
812
  submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
756
- // E9: pass through any flat-shape observation collisions detected at
813
+ // Pass through any flat-shape observation collisions detected at
757
814
  // normalize time so analyze() can publish them under
758
815
  // analyze.signal_origins_with_collisions.
759
816
  _signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
@@ -813,20 +870,20 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
813
870
  const cveRefs = playbook.domain.cve_refs || [];
814
871
  const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
815
872
  : (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
816
- // F17: distinguish OpenVEX/CycloneDX "drop entirely" dispositions
873
+ // Distinguish OpenVEX/CycloneDX "drop entirely" dispositions
817
874
  // (not_affected / false_positive) from "keep but annotate" dispositions
818
875
  // (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
819
876
  // is computed below from agentSignals.vex_fixed when the operator passes
820
877
  // it (CLI populates it from the VEX doc alongside vex_filter).
821
878
  const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
822
879
  : (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
823
- // F20: wrap xref.byCve() so a corrupt catalog (or transient missing-index
880
+ // Wrap xref.byCve() so a corrupt catalog (or transient missing-index
824
881
  // anomaly) surfaces as a runtime_error rather than crashing analyze().
825
882
  const _byCveSafe = (id) => {
826
883
  try { return xref.byCve(id); }
827
884
  catch (e) {
828
885
  if (Array.isArray(runOpts._runErrors)) {
829
- runOpts._runErrors.push({ kind: 'xref', cve_id: id, message: (e && e.message) ? String(e.message) : String(e) });
886
+ pushRunError(runOpts._runErrors, { kind: 'xref', cve_id: id, message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.cve_id || '' });
830
887
  }
831
888
  return { found: false, cve_id: id };
832
889
  }
@@ -838,7 +895,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
838
895
  const vexDropped = vexFilter
839
896
  ? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
840
897
  : [];
841
- // F17: VEX-fixed CVEs remain in matched/catalog arrays but get annotated
898
+ // VEX-fixed CVEs remain in matched/catalog arrays but get annotated
842
899
  // with vex_status:'fixed' downstream so consumers see them as resolved.
843
900
  const vexFixedIds = vexFixed
844
901
  ? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
@@ -875,7 +932,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
875
932
  }
876
933
  }
877
934
 
878
- // F3: indicator-level cve_ref correlation. Indicators may declare a
935
+ // Indicator-level cve_ref correlation. Indicators may declare a
879
936
  // cve_ref (string OR string[]) naming CVEs whose presence the indicator
880
937
  // pattern-matches. When such an indicator fires AND the named CVE exists
881
938
  // in the catalog, the CVE joins matched_cves with correlated_via=
@@ -914,7 +971,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
914
971
  // carry a non-null correlated_via array; catalog_baseline_cves entries
915
972
  // carry correlated_via:null and a `note` clarifying the field's intent.
916
973
  const cveShape = (c, correlatedVia) => {
917
- // F17: annotate VEX-fixed CVEs with vex_status. matched_cves still
974
+ // Annotate VEX-fixed CVEs with vex_status. matched_cves still
918
975
  // includes them so audit trails and SBOM reports surface "we know this
919
976
  // is in scope but vendor declared it fixed."
920
977
  const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
@@ -951,26 +1008,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
951
1008
 
952
1009
  // RWEP composition: start from the per-CVE rwep_score of evidence-correlated
953
1010
  // matches (NOT catalog baseline) so RWEP base reflects what the operator's
954
- // evidence actually surfaced. F18: the "max" reduction across matched CVEs
955
- // is intentional — RWEP is a "worst-case real-world exploit priority", not
1011
+ // evidence actually surfaced. The "max" reduction across matched CVEs is
1012
+ // intentional — RWEP is a "worst-case real-world exploit priority", not
956
1013
  // an arithmetic average. The most-exploitable CVE in the set drives the
957
1014
  // base; secondary CVEs add via rwep_inputs adjustments below rather than
958
1015
  // through base summing (which would double-count overlapping risk).
959
- // F17: vex_status='fixed' CVEs do NOT drive the base — vendor declared
960
- // them resolved. They still appear in matched_cves for audit traceability
961
- // but don't elevate RWEP.
1016
+ // vex_status='fixed' CVEs do NOT drive the base — vendor declared them
1017
+ // resolved. They still appear in matched_cves for audit traceability but
1018
+ // don't elevate RWEP.
962
1019
  const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
963
1020
  const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
964
1021
 
965
- // F5: rwep_factor semantics. Each rwep_input.weight is conditional on the
966
- // matched CVE having a corresponding attribute. Pre-fix, every weight fired
967
- // unconditionally when its signal_id indicator hit operators saw RWEP +25
968
- // for active_exploitation regardless of whether the matched CVE was actually
969
- // under active exploitation. Now we multiply weight by a factor in [0, 1]
970
- // derived from the first matched CVE's catalog attribute. blast_radius is
971
- // sourced from the analyze-phase blast_radius_score / 5 (rubric ceiling).
972
- // Negative weights (patch_available, live_patch_available) keep their sign
973
- // so a patched CVE deducts the full magnitude when the catalog confirms a
1022
+ // rwep_factor semantics: each rwep_input.weight is conditional on the
1023
+ // matched CVE having a corresponding attribute. Multiply weight by a
1024
+ // factor in [0, 1] derived from the first matched CVE's catalog
1025
+ // attribute so a weight only fires when its CVE-attribute supports it
1026
+ // (e.g. active_exploitation +25 only when the matched CVE is under
1027
+ // active exploitation). blast_radius is sourced from the analyze-phase
1028
+ // blast_radius_score / 5 (rubric ceiling). Negative weights
1029
+ // (patch_available, live_patch_available) keep their sign so a patched
1030
+ // CVE deducts the full magnitude when the catalog confirms a
974
1031
  // patch is available.
975
1032
  //
976
1033
  // Aliasing: playbooks ship rwep_factor values `public_poc` and
@@ -1018,12 +1075,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1018
1075
  }
1019
1076
  };
1020
1077
 
1021
- // F6: blast_radius_score validation. Pre-fix, when no agent signal was
1022
- // supplied the runner silently defaulted to blast_rubric[0].blast_radius_score
1023
- // typically the LOWEST-blast rubric entry which is the opposite of
1024
- // safe-default. Now: no supplied value null + signal='default'. Supplied
1025
- // value out of [0,5] null + signal='rejected' + runtime_error. Supplied
1026
- // value in range → use it + signal='supplied'.
1078
+ // blast_radius_score validation. No supplied value null +
1079
+ // signal='default'. Supplied value out of [0,5] → null +
1080
+ // signal='rejected' + runtime_error. Supplied value in range use it +
1081
+ // signal='supplied'. The runner never defaults to a rubric entry — that
1082
+ // would be the opposite of safe-default when the rubric's lowest entry
1083
+ // is the LOWEST-blast row.
1027
1084
  const blastRubric = an.blast_radius_model?.scoring_rubric || [];
1028
1085
  let blastRadiusScore = null;
1029
1086
  let blastRadiusSignal = 'default';
@@ -1036,11 +1093,11 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1036
1093
  } else {
1037
1094
  blastRadiusSignal = 'rejected';
1038
1095
  if (Array.isArray(runOpts._runErrors)) {
1039
- runOpts._runErrors.push({ kind: 'blast_radius_invalid', supplied: raw, reason: 'expected number in [0, 5]' });
1096
+ pushRunError(runOpts._runErrors, { kind: 'blast_radius_invalid', supplied: raw, reason: 'expected number in [0, 5]' }, { dedupeKey: e => String(e.supplied) });
1040
1097
  }
1041
1098
  }
1042
1099
  }
1043
- // F5: use the first evidence-correlated CVE as the canonical attribute
1100
+ // Use the first evidence-correlated CVE as the canonical attribute
1044
1101
  // source for factor scaling. If matchedCves is empty there's no per-CVE
1045
1102
  // evidence to gate on. v0.12.15: the prior fallback was
1046
1103
  // `factorCve = null` → every factor returned 0 → catalog-shape playbooks
@@ -1133,21 +1190,20 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1133
1190
  // detect.classification = inconclusive → theater_verdict = pending_agent_run
1134
1191
  // Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
1135
1192
  //
1136
- // F24: validate against an allowlist. Pre-fix, any free-text string the
1137
- // operator passed through agentSignals.theater_verdict was accepted, so
1138
- // downstream consumers (CSAF/SARIF/OpenVEX) emitted bundles with garbage
1139
- // verdicts like "TODO" or "let me think". Allowlist: clear, present,
1140
- // theater, pending_agent_run, unknown.
1193
+ // Validate agentSignals.theater_verdict against an allowlist so
1194
+ // downstream consumers (CSAF/SARIF/OpenVEX) never emit bundles with
1195
+ // garbage verdicts like "TODO" or free-text strings. Allowlist: clear,
1196
+ // present, theater, pending_agent_run, unknown.
1141
1197
  const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
1142
1198
  let theaterVerdict = agentSignals.theater_verdict;
1143
1199
  if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
1144
1200
  if (theaterVerdict !== undefined && theaterVerdict !== null && !_theaterAllowlist.has(theaterVerdict)) {
1145
1201
  if (Array.isArray(runOpts._runErrors)) {
1146
- runOpts._runErrors.push({
1202
+ pushRunError(runOpts._runErrors, {
1147
1203
  kind: 'theater_verdict_invalid',
1148
1204
  supplied: theaterVerdict,
1149
1205
  allowed: Array.from(_theaterAllowlist),
1150
- });
1206
+ }, { dedupeKey: e => String(e.supplied) });
1151
1207
  }
1152
1208
  theaterVerdict = undefined;
1153
1209
  }
@@ -1193,12 +1249,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1193
1249
  // matched_cves when surfacing "what CVEs is the operator actually
1194
1250
  // affected by based on submitted evidence?"
1195
1251
  catalog_baseline_cves: catalogBaselineEntries,
1196
- // F18: rwep base is reduced via Math.max across matched CVEs. Surface
1197
- // the reduction strategy as a discoverable field so operators reading the
1252
+ // rwep base is reduced via Math.max across matched CVEs. Surface the
1253
+ // reduction strategy as a discoverable field so operators reading the
1198
1254
  // bundle understand the semantics without grepping source.
1199
1255
  rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
1200
1256
  blast_radius_score: blastRadiusScore,
1201
- // F6: visible annotation of where blast_radius_score came from:
1257
+ // Visible annotation of where blast_radius_score came from:
1202
1258
  // 'supplied' — operator/agent provided a value in [0, 5].
1203
1259
  // 'default' — no value supplied; runner returned null (no rubric guess).
1204
1260
  // 'rejected' — value supplied but out of range; treated as default + runtime_error.
@@ -1209,7 +1265,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1209
1265
  audit_evidence: an.compliance_theater_check?.audit_evidence,
1210
1266
  reality_test: an.compliance_theater_check?.reality_test,
1211
1267
  verdict: theaterVerdict,
1212
- // F25: render verdict_text for both 'theater' AND 'present' verdicts
1268
+ // Render verdict_text for both 'theater' AND 'present' verdicts
1213
1269
  // ('present' is a synonym used by some playbooks for "theater is here").
1214
1270
  verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
1215
1271
  ? an.compliance_theater_check?.theater_verdict_if_gap
@@ -1231,14 +1287,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1231
1287
  ? `${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.`
1232
1288
  : "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
1233
1289
  } : null,
1234
- // E3: regex-eval failures surfaced here so operators can see WHICH
1290
+ // Regex-eval failures surfaced here so operators can see WHICH
1235
1291
  // condition expression crashed without the runner dying. Only present
1236
1292
  // when at least one evalCondition() call hit a regex exception during
1237
1293
  // this analyze pass; runOpts._runErrors is the same accumulator
1238
1294
  // populated by run() across all phases, so callers reading this field
1239
1295
  // see every regex problem in the run.
1240
1296
  runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
1241
- // E9: collisions when two flat-shape observations targeted the same
1297
+ // Collisions when two flat-shape observations targeted the same
1242
1298
  // indicator id. Empty when there were no collisions or no flat-shape
1243
1299
  // observations submitted.
1244
1300
  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() : [])
@@ -1248,8 +1304,8 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1248
1304
  /**
1249
1305
  * Extract VEX disposition sets from a CycloneDX/OpenVEX document.
1250
1306
  *
1251
- * F17: pre-fix this conflated OpenVEX `fixed` and `not_affected` into one
1252
- * "drop" set. They have different semantics:
1307
+ * OpenVEX `fixed` and `not_affected` must NOT collapse into a single
1308
+ * "drop" set they have different semantics:
1253
1309
  *
1254
1310
  * - not_affected / false_positive → drop from matched_cves entirely.
1255
1311
  * The vendor has formally declared the product not vulnerable; the CVE
@@ -1298,7 +1354,7 @@ function vexFilterFromDoc(doc) {
1298
1354
 
1299
1355
  function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
1300
1356
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
1301
- // E3: surface evalCondition regex errors raised here into the same
1357
+ // Surface evalCondition regex errors raised here into the same
1302
1358
  // run-wide accumulator that analyze() reads.
1303
1359
  const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
1304
1360
  const v = resolvedPhase(playbook, directiveId, 'validate');
@@ -1322,7 +1378,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1322
1378
  // weren't verified — the agent can surface that to the operator.
1323
1379
  if (!selected && paths.length) selected = paths[0];
1324
1380
 
1325
- // F26: selected_remediation selection logic:
1381
+ // selected_remediation selection logic:
1326
1382
  // 1. Iterate remediation_paths sorted by priority ASC (lower number =
1327
1383
  // higher priority per schema convention).
1328
1384
  // 2. Pick the FIRST path whose every precondition (evaluated against
@@ -1335,18 +1391,17 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1335
1391
  // precondition trace so operators can see why a higher-priority path was
1336
1392
  // skipped.
1337
1393
 
1338
- // F10: regression schedule. Pre-fix this returned a single ISO string;
1339
- // now returns a structured object with next_run + event_triggers +
1340
- // unparseable. Preserve backwards compatibility by keeping
1394
+ // Regression schedule. Returns a structured object with next_run +
1395
+ // event_triggers + unparseable. Backwards compatibility: keep
1341
1396
  // regression_next_run as the ISO string (or null) so existing CSAF /
1342
1397
  // attestation consumers don't break; expose the structured form
1343
1398
  // separately.
1344
1399
  const triggers = v.regression_trigger || [];
1345
1400
  const regressionResult = computeRegressionNextRun(triggers);
1346
1401
 
1347
- // F30: reason annotation for null next_run — operators see WHY a
1348
- // schedule didn't emit a calendar date (no day intervals declared,
1349
- // every trigger is event-driven, or every trigger was unparseable).
1402
+ // Reason annotation for null next_run — operators see WHY a schedule
1403
+ // didn't emit a calendar date (no day intervals declared, every trigger
1404
+ // is event-driven, or every trigger was unparseable).
1350
1405
  let nextRunReason = null;
1351
1406
  if (!regressionResult.next_run) {
1352
1407
  if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
@@ -1377,15 +1432,15 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1377
1432
  }
1378
1433
 
1379
1434
  /**
1380
- * F10: extended interval parser. Supports:
1435
+ * Extended interval parser. Supports:
1381
1436
  * <N>d — N days
1382
1437
  * <N>wk — N weeks
1383
1438
  * <N>mo — N calendar months (Date.setMonth semantics)
1384
1439
  * <N>yr — N calendar years
1385
1440
  * on_event — event-triggered, no date computed; surfaces in
1386
1441
  * regression_event_triggers[] for the consumer.
1387
- * Pre-fix, only Nd was honored; wk/mo/yr/on_event triggers were silently
1388
- * dropped, so a playbook declaring "regression on every release" or
1442
+ * Without all five forms, a playbook declaring "regression on every
1443
+ * release" or
1389
1444
  * "monthly review" lost its schedule entry.
1390
1445
  */
1391
1446
  function parseInterval(intervalStr, now) {
@@ -1473,12 +1528,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1473
1528
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1474
1529
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1475
1530
  );
1476
- // E7: thread runOpts through so computeClockStart can check
1531
+ // Thread runOpts through so computeClockStart can check
1477
1532
  // operator_consent.explicit before auto-stamping detect_confirmed.
1478
1533
  const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
1479
- // E7: when the clock event is detect_confirmed AND the classification
1480
- // matched AND the operator did NOT pass --ack, surface clock_pending_ack
1481
- // so the notification record is visibly waiting on acknowledgement.
1534
+ // When the clock event is detect_confirmed AND the classification
1535
+ // matched AND the operator did NOT pass --ack, surface
1536
+ // clock_pending_ack so the notification record is visibly waiting on
1537
+ // acknowledgement.
1482
1538
  const clockPendingAck = !clockStart
1483
1539
  && obligation?.clock_starts === 'detect_confirmed'
1484
1540
  && agentSignals?.detection_classification === 'detected'
@@ -1504,20 +1560,20 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1504
1560
  // Evidence the regulator expects attached (from the obligation, not
1505
1561
  // just the operator-facing recipient bundle on the notification entry).
1506
1562
  evidence_required: obligation?.evidence_required || na.evidence_attached || [],
1507
- // F14: track missing interpolation variables so operators see exactly
1563
+ // Track missing interpolation variables so operators see exactly
1508
1564
  // which template vars failed to resolve. Empty array when all
1509
1565
  // placeholders rendered cleanly.
1510
1566
  ...(function () {
1511
1567
  const missing = [];
1512
- // F20: analyzeFindingShape is a pure transform but defensive-wrap
1513
- // it so a malformed analyze result (missing matched_cves, etc.)
1568
+ // analyzeFindingShape is a pure transform but defensive-wrap it
1569
+ // so a malformed analyze result (missing matched_cves, etc.)
1514
1570
  // can't bring down the whole close phase. Failures surface in
1515
1571
  // runtime_errors via runOpts._runErrors when available.
1516
1572
  let findingShape;
1517
1573
  try { findingShape = analyzeFindingShape(analyzeResult); }
1518
1574
  catch (e) {
1519
1575
  if (Array.isArray(runOpts._runErrors)) {
1520
- runOpts._runErrors.push({ kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) });
1576
+ pushRunError(runOpts._runErrors, { kind: 'analyze_shape', message: (e && e.message) ? String(e.message) : String(e) }, { dedupeKey: e => e.message || '' });
1521
1577
  }
1522
1578
  findingShape = {};
1523
1579
  }
@@ -1564,13 +1620,13 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1564
1620
  const extraFormats = Array.isArray(agentSignals._bundle_formats)
1565
1621
  ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
1566
1622
  : [];
1567
- // B: build every bundle once and reuse, so bundle_body and
1568
- // bundles_by_format[primary] are the same object identity (and hence
1569
- // identical on every nested timestamp). Pre-fix, buildEvidenceBundle was
1570
- // invoked twice for the primary format and each invocation crystallised
1571
- // a fresh Date.now() — operators diffing bundle_body against
1572
- // bundles_by_format.<primary> saw spurious millisecond drift on
1573
- // tracking.initial_release_date / timestamp / current_release_date.
1623
+ // Build every bundle once and reuse, so bundle_body and
1624
+ // bundles_by_format[primary] share object identity (and timestamps).
1625
+ // Without memoisation, buildEvidenceBundle gets invoked twice for the
1626
+ // primary format and each invocation crystallises a fresh Date.now() —
1627
+ // operators diffing bundle_body against bundles_by_format.<primary> see
1628
+ // spurious millisecond drift on tracking.initial_release_date /
1629
+ // timestamp / current_release_date.
1574
1630
  const evidencePackage = c.evidence_package ? (() => {
1575
1631
  const issuedAt = new Date().toISOString();
1576
1632
  const builtFormats = new Map();
@@ -1639,8 +1695,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1639
1695
  validate: validateResult,
1640
1696
  finding: analyzeFindingShape(analyzeResult),
1641
1697
  ...agentSignals,
1642
- // E3: surface evalCondition regex failures from the feeds_into chain
1643
- // into the same accumulator. Without this the regex failure happens but
1698
+ // Surface evalCondition regex failures from the feeds_into chain into
1699
+ // the same accumulator. Without this the regex failure happens but
1644
1700
  // analyze.runtime_errors[] never sees it.
1645
1701
  ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
1646
1702
  };
@@ -1665,7 +1721,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1665
1721
  exception: exception,
1666
1722
  regression_schedule: regressionSchedule,
1667
1723
  feeds_into: feeds,
1668
- // F21: feeds_into surfaces downstream playbook IDs whose preconditions
1724
+ // feeds_into surfaces downstream playbook IDs whose preconditions
1669
1725
  // were satisfied by this run. The runner does NOT automatically chain
1670
1726
  // into them — the agent / operator decides whether to invoke them.
1671
1727
  // Surface that contract on the result so consumers don't assume an
@@ -1674,7 +1730,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1674
1730
  };
1675
1731
  }
1676
1732
 
1677
- // E8: severity ladder for active_exploitation. The worst-of reduction lets
1733
+ // Severity ladder for active_exploitation. The worst-of reduction lets
1678
1734
  // analyzeFindingShape report the most-exploited CVE in the matched set, not
1679
1735
  // the first-encountered one. Higher index = worse.
1680
1736
  const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
@@ -1691,10 +1747,10 @@ function worstActiveExploitation(matchedCves) {
1691
1747
  return worst || 'unknown';
1692
1748
  }
1693
1749
 
1694
- // F4: severity ladder derived from rwep_adjusted. Playbooks reference
1695
- // `finding.severity` in feeds_into and escalation_criteria conditions but
1696
- // pre-fix analyzeFindingShape never emitted it, so those conditions silently
1697
- // resolved against undefined. Thresholds:
1750
+ // Severity ladder derived from rwep_adjusted. Playbooks reference
1751
+ // `finding.severity` in feeds_into and escalation_criteria conditions;
1752
+ // emit it so those conditions resolve against a real value rather than
1753
+ // undefined. Thresholds:
1698
1754
  // rwep >= 80 → critical
1699
1755
  // rwep >= 50 → high
1700
1756
  // rwep >= 20 → medium
@@ -1712,22 +1768,21 @@ function analyzeFindingShape(a) {
1712
1768
  const rwepAdjusted = a.rwep?.adjusted ?? 0;
1713
1769
  return {
1714
1770
  matched_cve_ids: matched.map(c => c.cve_id).join(', '),
1715
- // F19: sibling array form for consumers that want to iterate IDs
1716
- // without re-splitting the joined string. The joined form stays for
1717
- // backwards compatibility with notification-draft templates that
1718
- // interpolate `${matched_cve_ids}` verbatim.
1771
+ // Sibling array form for consumers that want to iterate IDs without
1772
+ // re-splitting the joined string. The joined form stays for backwards
1773
+ // compatibility with notification-draft templates that interpolate
1774
+ // `${matched_cve_ids}` verbatim.
1719
1775
  matched_cve_ids_array: matched.map(c => c.cve_id),
1720
1776
  matched_cve_count: matched.length,
1721
1777
  kev_listed_count: matched.filter(c => c.cisa_kev).length,
1722
- // E8: previously this used .find() which returned the first matched CVE
1723
- // with a truthy active_exploitation. With two CVEs where #1 is
1724
- // 'suspected' and #2 is 'confirmed', operators saw 'suspected' on
1725
- // notification drafts — under-stating the threat. Now reduce to the
1726
- // worst rank across all matched CVEs.
1778
+ // Reduce active_exploitation to the worst rank across all matched
1779
+ // CVEs. A .find() lookup would return the first truthy entry — e.g.
1780
+ // 'suspected' on CVE #1 when CVE #2 is 'confirmed' under-stating
1781
+ // the threat in notification drafts.
1727
1782
  active_exploitation: worstActiveExploitation(matched),
1728
1783
  rwep_adjusted: rwepAdjusted,
1729
1784
  rwep_base: a.rwep?.base ?? 0,
1730
- // F4: severity surface for playbook conditions.
1785
+ // Severity surface for playbook conditions.
1731
1786
  severity: severityForRwep(rwepAdjusted),
1732
1787
  blast_radius_score: a.blast_radius_score ?? 0,
1733
1788
  framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
@@ -1735,6 +1790,121 @@ function analyzeFindingShape(a) {
1735
1790
  };
1736
1791
  }
1737
1792
 
1793
+ // Route a vulnerability identifier to its registry-specific URN namespace.
1794
+ // CVE-/GHSA-/RUSTSEC-/MAL-* identifiers each have a registered URN namespace;
1795
+ // unrecognised prefixes route to the `urn:exceptd:advisory:` private
1796
+ // namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
1797
+ function vulnIdToUrn(id) {
1798
+ const slug = urnSlug(id);
1799
+ if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${slug}`;
1800
+ if (/^CVE-/i.test(id)) return `urn:cve:${slug}`;
1801
+ if (/^GHSA-/i.test(id)) return `urn:ghsa:${slug}`;
1802
+ if (/^RUSTSEC-/i.test(id)) return `urn:rustsec:${slug}`;
1803
+ if (/^MAL-/i.test(id)) return `urn:malicious-package:${slug}`;
1804
+ return `urn:exceptd:advisory:${slug}`;
1805
+ }
1806
+
1807
+ // Build a CSAF product_tree.branches[] tree (vendor → product_name →
1808
+ // product_version). Sources of vendor/product/version, in priority order:
1809
+ // (1) catalog entry `affected_products: [{ vendor, product, version }]`
1810
+ // (2) heuristic parse of `affected_components[]` strings — accepts
1811
+ // `vendor/product@version` and `vendor product version` shapes.
1812
+ // Unparseable component strings emit a `csaf_branch_unparseable` runtime
1813
+ // error and are dropped from the tree. Sort alphabetical at each level so
1814
+ // the output is deterministic across runs.
1815
+ //
1816
+ // Returns `{ branches, productIds }`. productIds is a stable enumeration
1817
+ // CSAFPID-0..N keyed by (vendor, product, version) insertion order so other
1818
+ // emit paths can reference the leaf products by id later.
1819
+ function buildCsafBranches(matchedCves, runOpts) {
1820
+ // Build a (vendor → product → Set<version>) map.
1821
+ const tree = new Map();
1822
+ const addLeaf = (vendor, product, version) => {
1823
+ if (!vendor || !product || !version) return;
1824
+ if (!tree.has(vendor)) tree.set(vendor, new Map());
1825
+ const products = tree.get(vendor);
1826
+ if (!products.has(product)) products.set(product, new Set());
1827
+ products.get(product).add(version);
1828
+ };
1829
+
1830
+ // Heuristic parser. Returns { vendor, product, version } or null.
1831
+ const parseComponentString = (s) => {
1832
+ if (typeof s !== 'string' || !s.trim()) return null;
1833
+ const trimmed = s.trim();
1834
+ // `vendor/product@version`
1835
+ let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
1836
+ if (m) return { vendor: m[1], product: m[2], version: m[3].trim() };
1837
+ // `vendor product version` — exactly three whitespace-separated tokens
1838
+ // where the last token starts with a digit or `v\d`.
1839
+ const parts = trimmed.split(/\s+/);
1840
+ if (parts.length >= 3) {
1841
+ const last = parts[parts.length - 1];
1842
+ if (/^v?\d/.test(last)) {
1843
+ return { vendor: parts[0], product: parts.slice(1, -1).join(' '), version: last };
1844
+ }
1845
+ }
1846
+ return null;
1847
+ };
1848
+
1849
+ for (const c of matchedCves || []) {
1850
+ if (Array.isArray(c.affected_products) && c.affected_products.length > 0) {
1851
+ for (const ap of c.affected_products) {
1852
+ if (ap && typeof ap === 'object' && ap.vendor && ap.product && ap.version) {
1853
+ addLeaf(String(ap.vendor), String(ap.product), String(ap.version));
1854
+ }
1855
+ }
1856
+ continue;
1857
+ }
1858
+ const components = Array.isArray(c.affected_components) ? c.affected_components
1859
+ : (Array.isArray(c.affected_versions) ? c.affected_versions : []);
1860
+ for (const comp of components) {
1861
+ const parsed = parseComponentString(comp);
1862
+ if (parsed) {
1863
+ addLeaf(parsed.vendor, parsed.product, parsed.version);
1864
+ } else if (typeof comp === 'string' && comp.trim() && runOpts && Array.isArray(runOpts._runErrors)) {
1865
+ pushRunError(runOpts._runErrors, {
1866
+ kind: 'csaf_branch_unparseable',
1867
+ component: String(comp),
1868
+ cve_id: c.cve_id || null,
1869
+ }, { dedupeKey: e => `${e.cve_id || ''}::${e.component}` });
1870
+ }
1871
+ }
1872
+ }
1873
+
1874
+ // Sort + emit.
1875
+ const productIds = [];
1876
+ let pidCounter = 0;
1877
+ const vendors = Array.from(tree.keys()).sort();
1878
+ const branches = vendors.map(vendor => {
1879
+ const products = tree.get(vendor);
1880
+ const productNames = Array.from(products.keys()).sort();
1881
+ return {
1882
+ category: 'vendor',
1883
+ name: vendor,
1884
+ branches: productNames.map(product => {
1885
+ const versions = Array.from(products.get(product)).sort();
1886
+ return {
1887
+ category: 'product_name',
1888
+ name: product,
1889
+ branches: versions.map(version => {
1890
+ const pid = `CSAFPID-${pidCounter++}`;
1891
+ productIds.push({ vendor, product, version, product_id: pid });
1892
+ return {
1893
+ category: 'product_version',
1894
+ name: version,
1895
+ product: {
1896
+ name: `${vendor}/${product}@${version}`,
1897
+ product_id: pid,
1898
+ },
1899
+ };
1900
+ }),
1901
+ };
1902
+ }),
1903
+ };
1904
+ });
1905
+ return { branches, productIds };
1906
+ }
1907
+
1738
1908
  // Slugify a string into a URN-safe segment ([a-z0-9_-]+ per RFC 8141 NSS).
1739
1909
  // Empty input → 'unknown' so we never emit zero-length segments.
1740
1910
  function urnSlug(s) {
@@ -1769,7 +1939,7 @@ function buildProductBinding(playbook, sessionId) {
1769
1939
  // surface at least one candidate when any is known. Returns null when no
1770
1940
  // candidate exists — caller MUST omit `locations` rather than emit empty.
1771
1941
  //
1772
- // A: source segments are heterogeneous — many playbook artifacts
1942
+ // Source segments are heterogeneous — many playbook artifacts
1773
1943
  // describe a shell-command capture (`uname -r`) or human prose, not a real
1774
1944
  // file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
1775
1945
  // (RFC 3986); shell-command text + prose breaks downstream consumers
@@ -1826,24 +1996,24 @@ function getEngineVersion() {
1826
1996
  return _CACHED_PKG_VERSION;
1827
1997
  }
1828
1998
 
1829
- // 3 / P1-4: operator-supplied identity strings (--operator) and
1830
- // publisher namespace URLs (--publisher-namespace) flow into operator-facing
1831
- // CSAF surfaces. Strip ASCII control characters as a defence-in-depth pass
1832
- // bin/exceptd.js already validates the inputs, but the runner is also called
1833
- // from library consumers that may bypass the CLI surface.
1999
+ // Operator-supplied identity strings (--operator) and publisher namespace
2000
+ // URLs (--publisher-namespace) flow into operator-facing CSAF surfaces.
2001
+ // Strip ASCII control characters as defence in depth bin/exceptd.js
2002
+ // already validates the CLI inputs, but the runner is also called from
2003
+ // library consumers that may bypass the CLI surface.
1834
2004
  //
1835
- // MM P1-D: extend the strip to Unicode bidi / format / control / surrogate /
1836
- // private-use / unassigned categories (\p{C} under the `u` regex flag) so
1837
- // direct library callers of buildEvidenceBundle cannot smuggle a U+202E
1838
- // "RTL OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
1839
- // already refuses (--operator validation in bin/exceptd.js). NFC-normalise
1840
- // first so a decomposed sequence can't combine past the codepoint check;
1841
- // cap the result at 256 codepoints (NOT UTF-16 code units) so a string of
1842
- // astral-plane codepoints can't smuggle a longer-than-256-display string
1843
- // past the cap by exploiting JavaScript's surrogate-pair string length.
1844
- // Returns null on rejection (empty after strip, or NFC normalise threw);
1845
- // callers (the publisher-namespace + contact_details + tracking.generator
1846
- // sites) treat null as "operator-unclaimed" and route through the existing
2005
+ // Strip Unicode bidi / format / control / surrogate / private-use /
2006
+ // unassigned categories (\p{C} under the `u` regex flag) so direct
2007
+ // library callers of buildEvidenceBundle cannot smuggle a U+202E "RTL
2008
+ // OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
2009
+ // already refuses. NFC-normalise first so a decomposed sequence can't
2010
+ // combine past the codepoint check; cap the result at 256 codepoints
2011
+ // (NOT UTF-16 code units) so a string of astral-plane codepoints can't
2012
+ // smuggle a longer-than-256-display string past the cap by exploiting
2013
+ // JavaScript's surrogate-pair string length. Returns null on rejection
2014
+ // (empty after strip, or NFC normalise threw); callers (the
2015
+ // publisher-namespace + contact_details + tracking.generator sites)
2016
+ // treat null as "operator-unclaimed" and route through the existing
1847
2017
  // fallback (publisher.namespace = urn:exceptd:operator:unknown +
1848
2018
  // bundle_publisher_unclaimed runtime warning).
1849
2019
  function sanitizeOperatorText(s) {
@@ -1867,16 +2037,60 @@ function sanitizeOperatorText(s) {
1867
2037
  return cps.slice(0, 256).join('');
1868
2038
  }
1869
2039
 
2040
+ /**
2041
+ * Build a single evidence bundle in the requested machine-readable format.
2042
+ *
2043
+ * Positional contract — the seven phase functions cache the closure over
2044
+ * `playbook`, `analyze`, and `validate` so consumers don't reach into the
2045
+ * runner's intermediate state. Library callers that bypass close() (e.g.
2046
+ * external dashboards re-rendering a stored attestation) MUST honor the
2047
+ * same parameter order, names, and types.
2048
+ *
2049
+ * @param {string} format Output dialect. One of: 'csaf-2.0',
2050
+ * 'sarif' / 'sarif-2.1.0', 'openvex' /
2051
+ * 'openvex-0.2.0', 'summary', 'markdown'.
2052
+ * Unknown values return a stub with
2053
+ * supported_formats so callers can branch.
2054
+ * @param {object} playbook Playbook record loaded via loadPlaybook().
2055
+ * Provides _meta.id / version, domain.name,
2056
+ * phases.look.artifacts (for SARIF
2057
+ * locations), and feeds_into / mutex.
2058
+ * @param {object} analyze Output of analyze(). Carries matched_cves,
2059
+ * _detect_indicators, framework_gap_mapping,
2060
+ * rwep, blast_radius_score,
2061
+ * _detect_classification.
2062
+ * @param {object} validate Output of validate(). Carries
2063
+ * selected_remediation, remediation_paths,
2064
+ * evidence_requirements,
2065
+ * residual_risk_statement.
2066
+ * @param {object} agentSignals Agent-submitted signals (signal_overrides
2067
+ * merged + cleaned). Drives the OpenVEX
2068
+ * vex_status:'fixed' attestation trail and
2069
+ * the CSAF cvss_v3 score-block gate.
2070
+ * @param {string} sessionId Run session id (threaded from run()).
2071
+ * Becomes part of CSAF tracking.id,
2072
+ * OpenVEX @id, and the on-disk attestation
2073
+ * file name so all three correlate.
2074
+ * @param {string=} issuedAt Optional ISO 8601 timestamp. Pinning this
2075
+ * across multi-format emits keeps CSAF /
2076
+ * OpenVEX / SARIF agreed on milliseconds;
2077
+ * each call would otherwise crystallise a
2078
+ * fresh Date.now().
2079
+ * @param {object=} runOpts Operator / library knobs. Recognised
2080
+ * fields: operator, publisherNamespace,
2081
+ * csafStatus, tlp, _runErrors accumulator.
2082
+ * @returns {object} The requested format's document body.
2083
+ */
1870
2084
  function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
1871
2085
  runOpts = runOpts || {};
1872
2086
  const playbookSlug = urnSlug(playbook._meta.id);
1873
2087
  const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1874
- // B: pin one `now` value per bundle build (and accept an
2088
+ // Pin one `now` value per bundle build (and accept an
1875
2089
  // upstream-provided issuedAt) so multi-format emit produces identical
1876
2090
  // tracking timestamps across CSAF / OpenVEX / SARIF when close() is
1877
2091
  // building several formats from the same run. Without the parameter,
1878
- // each invocation crystallised a fresh `Date.now()` and bundle_body
1879
- // versus bundles_by_format[primary] would diverge on milliseconds.
2092
+ // each invocation crystallises a fresh `Date.now()` and bundle_body
2093
+ // versus bundles_by_format[primary] diverge on milliseconds.
1880
2094
  const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
1881
2095
 
1882
2096
  // CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
@@ -1895,16 +2109,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1895
2109
  name: productName,
1896
2110
  product_identification_helper: { purl: productPurl }
1897
2111
  }];
1898
- // A: `fixed` product_status MUST reflect operator-supplied VEX
1899
- // disposition (vex_status === 'fixed' — see analyze() F17), not the
1900
- // catalog's global `live_patch_available` flag. The catalog flag means
1901
- // "vendor publishes a live-patch in the world", not "operator deployed
1902
- // it on this host". Pre-fix the CSAF emitter declared every
1903
- // live-patchable CVE as fixed regardless of whether the operator's
1904
- // evidence actually showed the patch applied, producing CSAF documents
1905
- // that lied to downstream NVD / Red Hat dashboards. When
1906
- // live_patch_available is the only signal, status stays known_affected
1907
- // and the live-patch route is surfaced as a `vendor_fix` remediation.
2112
+ // `fixed` product_status MUST reflect operator-supplied VEX
2113
+ // disposition (vex_status === 'fixed' — see analyze()), not the
2114
+ // catalog's global `live_patch_available` flag. The catalog flag
2115
+ // means "vendor publishes a live-patch in the world", not "operator
2116
+ // deployed it on this host". Declaring every live-patchable CVE as
2117
+ // fixed regardless of operator evidence would produce CSAF documents
2118
+ // that lie to downstream NVD / Red Hat dashboards. When
2119
+ // live_patch_available is the only signal, status stays
2120
+ // known_affected and the live-patch route is surfaced as a
2121
+ // `vendor_fix` remediation.
1908
2122
  // CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
1909
2123
  // regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
1910
2124
  // identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
@@ -1936,25 +2150,25 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1936
2150
  return m[1];
1937
2151
  };
1938
2152
  const csafIdsFor = (id) => {
1939
- // B: null / undefined / non-string id MUST NOT emit literal
1940
- // "null" / "undefined" text into the vulnerabilities[] entry. Pre-fix
1941
- // String(id) coerced both to those literals strict validators then
1942
- // rejected the document, and operators saw a phantom "null" CVE in
1943
- // dashboards. Return null so the caller can skip the entry entirely
1944
- // and surface a runtime_error for the missing id.
2153
+ // null / undefined / non-string id MUST NOT emit literal "null" /
2154
+ // "undefined" text into the vulnerabilities[] entry. String(id)
2155
+ // would coerce both to those literals; strict validators then
2156
+ // reject the document and operators see a phantom "null" CVE in
2157
+ // dashboards. Return null so the caller skips the entry entirely
2158
+ // and surfaces a runtime_error for the missing id.
1945
2159
  if (typeof id !== 'string' || !id) return null;
1946
2160
  if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
1947
2161
  if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
1948
2162
  if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
1949
2163
  if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
1950
- // A: RUSTSEC advisories carry their own tracking authority
1951
- // (https://rustsec.org); mis-routing them to system_name 'OSV' loses
1952
- // the upstream provenance link and confuses downstream ingesters that
1953
- // resolve by (system_name, text) pair.
2164
+ // RUSTSEC advisories carry their own tracking authority
2165
+ // (https://rustsec.org); mis-routing them to system_name 'OSV'
2166
+ // loses the upstream provenance link and confuses downstream
2167
+ // ingesters that resolve by (system_name, text) pair.
1954
2168
  if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
1955
- // B: genuinely-unknown prefix surfaces as `exceptd-unknown`
1956
- // so downstream ingesters know the authority wasn't recognized — pre-fix
1957
- // every unknown id was misattributed to OSV.
2169
+ // Genuinely-unknown prefix surfaces as `exceptd-unknown` so
2170
+ // downstream ingesters see that the authority wasn't recognised
2171
+ // rather than misattributing every unknown id to OSV.
1958
2172
  return { system_name: 'exceptd-unknown', text: id };
1959
2173
  };
1960
2174
  const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
@@ -1967,24 +2181,22 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1967
2181
  || (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
1968
2182
  product_ids: [productId],
1969
2183
  }];
1970
- // B: catalog entries with a missing / non-string cve_id
1971
- // pre-fix produced literal `text: "null"` / `text: "undefined"` entries
1972
- // under ids[]. Skip the vulnerability entry entirely and surface a
1973
- // runtime_error so the catalog gap is visible to operators / CI gates.
2184
+ // Catalog entries with a missing / non-string cve_id would
2185
+ // otherwise produce literal `text: "null"` / `text: "undefined"`
2186
+ // entries under ids[]. Skip the vulnerability entry entirely and
2187
+ // surface a runtime_error so the catalog gap is visible to
2188
+ // operators / CI gates.
1974
2189
  const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
1975
2190
  let idEntry = null;
1976
2191
  if (!idIsCve) {
1977
2192
  idEntry = csafIdsFor(c.cve_id);
1978
2193
  if (idEntry == null) {
1979
2194
  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
- }
2195
+ pushRunError(runOpts._runErrors, {
2196
+ kind: 'bundle_cve_id_missing',
2197
+ 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[].',
2198
+ remediation: 'Inspect the CVE catalog feed that produced this match; the upstream record is missing its identifier and should be refreshed or excluded.'
2199
+ }, { dedupeKey: () => 'singleton' });
1988
2200
  }
1989
2201
  return null;
1990
2202
  }
@@ -1997,24 +2209,32 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
1997
2209
  // an authoritative "informational" score where there was simply no
1998
2210
  // data.
1999
2211
  //
2000
- // C: CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1.
2001
- // Catalog vectors prefixed CVSS:2.0/ or CVSS:4.0/ would pre-fix emit a
2002
- // cvss_v3 block with version: '2.0' / '4.0', which strict validators
2003
- // (BSI CSAF Validator) reject outright. Drop the block for non-3.x
2004
- // vectors and surface a runtime_error so operators can see why their
2005
- // CVSS data didn't make it through.
2212
+ // CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1. Catalog
2213
+ // vectors prefixed CVSS:2.0/ or CVSS:4.0/ would otherwise emit a
2214
+ // cvss_v3 block with version: '2.0' / '4.0', which strict
2215
+ // validators (BSI CSAF Validator) reject outright. Drop the block
2216
+ // for non-3.x vectors and surface a runtime_error so operators can
2217
+ // see why their CVSS data didn't make it through.
2006
2218
  const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
2007
- const vectorVersion = hasCvss ? csafCvssVersionFromVector(c.cvss_vector) : null;
2008
- const cvssV3Eligible = hasCvss && (vectorVersion === '3.0' || vectorVersion === '3.1');
2219
+ // Strict CVSS 3.1 parse (lib/scoring.parseCvss31Vector). The pre-fix
2220
+ // permissive regex accepted any CVSS:X.Y/... prefix and would emit a
2221
+ // cvss_v3 block keyed off a malformed vector — strict validators
2222
+ // (BSI CSAF Validator, ENISA dashboard) then reject the whole
2223
+ // document. Strict parse failures surface as a `csaf_cvss_invalid`
2224
+ // runtime_error, the cvss_v3 block is omitted, and the rest of the
2225
+ // vulnerability entry (product_status, remediations, etc.) survives.
2226
+ let strictParse = null;
2227
+ if (hasCvss) {
2228
+ strictParse = scoring.parseCvss31Vector(c.cvss_vector);
2229
+ }
2230
+ const vectorVersion = hasCvss ? (strictParse && strictParse.version) : null;
2231
+ const cvssV3Eligible = !!(hasCvss && strictParse && strictParse.ok);
2009
2232
  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
- }
2233
+ pushRunError(runOpts._runErrors, {
2234
+ kind: 'csaf_cvss_invalid',
2235
+ cve_id: c.cve_id,
2236
+ reason: (strictParse && strictParse.reason) || 'cvss_vector failed strict CVSS 3.1 parse',
2237
+ }, { dedupeKey: e => e.cve_id || 'unknown' });
2018
2238
  }
2019
2239
  const scores = cvssV3Eligible ? [{
2020
2240
  products: [productId],
@@ -2047,15 +2267,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2047
2267
  remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
2048
2268
  product_status: { known_affected: [productId] }
2049
2269
  }));
2050
- // D: framework-gap entries used to ride in `vulnerabilities[]`
2051
- // with `ids: [{ system_name: 'exceptd-framework-gap' }]`. The
2052
- // `system_name` slot is reserved for recognised vulnerability tracking
2053
- // authorities (CVE, GHSA, etc.); exceptd-framework-gap is not one, and
2054
- // every downstream CSAF consumer (NVD ingester, Red Hat dashboard,
2055
- // ENISA validator) flagged every run for unknown ids and rendered
2056
- // false-positive advisories at the framework_gap_mapping length. Now
2057
- // framework gaps land in `document.notes[]` with `category: details`
2058
- // where they belong as advisory context, not pseudo-CVEs.
2270
+ // Framework-gap entries land in `document.notes[]` with
2271
+ // `category: details` rather than `vulnerabilities[]` with
2272
+ // `ids: [{ system_name: 'exceptd-framework-gap' }]`. The `system_name`
2273
+ // slot is reserved for recognised vulnerability tracking authorities
2274
+ // (CVE, GHSA, etc.); exceptd-framework-gap is not one, and every
2275
+ // downstream CSAF consumer (NVD ingester, Red Hat dashboard, ENISA
2276
+ // validator) would flag the run for unknown ids and render
2277
+ // false-positive advisories at the framework_gap_mapping length.
2278
+ // Notes are the right home for advisory context that is not itself
2279
+ // a pseudo-CVE.
2059
2280
  const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
2060
2281
  const lines = [
2061
2282
  `Framework: ${g.framework}`,
@@ -2105,14 +2326,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2105
2326
  // De-dupe: only push once per bundle-build pass (multi-format emit
2106
2327
  // builds CSAF once via memoization, so this fires at most once per run).
2107
2328
  if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
2108
- const already = runOpts._runErrors.some(e => e && e.kind === 'bundle_publisher_unclaimed');
2109
- if (!already) {
2110
- runOpts._runErrors.push({
2111
- kind: 'bundle_publisher_unclaimed',
2112
- reason: 'CSAF document.publisher.namespace fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Operator attribution is unclaimed on this advisory.',
2113
- remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2114
- });
2115
- }
2329
+ pushRunError(runOpts._runErrors, {
2330
+ kind: 'bundle_publisher_unclaimed',
2331
+ reason: 'CSAF document.publisher.namespace fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Operator attribution is unclaimed on this advisory.',
2332
+ remediation: 'Re-run with --publisher-namespace <https-url> (or a URL-shaped --operator).'
2333
+ }, { dedupeKey: () => 'singleton' });
2116
2334
  }
2117
2335
 
2118
2336
  // thread the validated --operator name into
@@ -2140,6 +2358,16 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2140
2358
  ? runOpts.csafStatus
2141
2359
  : 'interim';
2142
2360
 
2361
+ // CSAF §3.1.4 `distribution.tlp`. Optional. When the operator supplies
2362
+ // `--tlp <label>` (threaded as runOpts.tlp), emit
2363
+ // distribution.tlp.label + distribution.text. CSAF allows omission of
2364
+ // the whole distribution block when no level is declared; the
2365
+ // pre-fix runner had no surface for this at all.
2366
+ const allowedTlp = new Set(['CLEAR', 'GREEN', 'AMBER', 'AMBER+STRICT', 'RED']);
2367
+ const csafDistribution = (runOpts.tlp && allowedTlp.has(runOpts.tlp))
2368
+ ? { tlp: { label: runOpts.tlp }, text: `TLP:${runOpts.tlp}` }
2369
+ : null;
2370
+
2143
2371
  return {
2144
2372
  document: {
2145
2373
  category: 'csaf_security_advisory',
@@ -2147,6 +2375,7 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2147
2375
  publisher: publisherBlock,
2148
2376
  title: `exceptd finding: ${playbook.domain.name} (${analyze.matched_cves.length} CVE(s), ${indicatorHits.length} indicator hit(s), ${(analyze.framework_gap_mapping || []).length} framework gap(s))`,
2149
2377
  notes: [...namespaceFallbackNote, ...gapNotes],
2378
+ ...(csafDistribution ? { distribution: csafDistribution } : {}),
2150
2379
  tracking: {
2151
2380
  // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
2152
2381
  // from run() via close()) so attestation file names, OpenVEX
@@ -2168,7 +2397,19 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2168
2397
  revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
2169
2398
  }
2170
2399
  },
2171
- product_tree: { full_product_names: fullProductNames },
2400
+ product_tree: (function () {
2401
+ // Synthesize a 3-level branches tree (vendor → product → version)
2402
+ // from catalog data. CSAF §3.1.5.1 makes branches[] strongly
2403
+ // recommended for csaf_security_advisory documents because NVD /
2404
+ // ENISA / Red Hat dashboards render the affected-product list off
2405
+ // the branches tree, not full_product_names[]. The pre-fix tree
2406
+ // emitted only the synthetic exceptd-target product and operators
2407
+ // browsing the rendered advisory saw no real-world vendor surface.
2408
+ const { branches } = buildCsafBranches(analyze.matched_cves || [], runOpts);
2409
+ const tree = { full_product_names: fullProductNames };
2410
+ if (branches.length > 0) tree.branches = branches;
2411
+ return tree;
2412
+ })(),
2172
2413
  vulnerabilities: [...cveVulns, ...indicatorVulns],
2173
2414
  exceptd_extension: {
2174
2415
  classification: analyze._detect_classification,
@@ -2272,9 +2513,9 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2272
2513
  rules: [...cveRules, ...indicatorRules, ...gapRules],
2273
2514
  } },
2274
2515
  results: [...cveResults, ...indicatorResults, ...gapResults],
2275
- invocations: [{ executionSuccessful: true, properties: stripNulls({
2276
- // A: apply the B7 stripNulls contract here too — the
2277
- // `remediation` field is null for any run that didn't surface a
2516
+ invocations: [{ executionSuccessful: (analyze._detect_classification !== 'inconclusive'), properties: stripNulls({
2517
+ // Apply the stripNulls contract here too — the `remediation`
2518
+ // field is null for any run that didn't surface a
2278
2519
  // selected_remediation, and SARIF viewers render null property
2279
2520
  // values as visible empty rows. Same helper as the result
2280
2521
  // property bags above.
@@ -2302,11 +2543,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2302
2543
  // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
2303
2544
  // they pass IRI validation in downstream VEX consumers.
2304
2545
  if (format === 'openvex' || format === 'openvex-0.2.0') {
2305
- // B: reuse the bundle-wide `now` so OpenVEX `timestamp`
2306
- // aligns with CSAF `document.tracking.initial_release_date` when both
2307
- // formats are emitted in the same close() pass. Pre-fix each format
2308
- // crystallised its own Date.now() value, and the two bundles in
2309
- // bundles_by_format disagreed on milliseconds.
2546
+ // Reuse the bundle-wide `now` so OpenVEX `timestamp` aligns with
2547
+ // CSAF `document.tracking.initial_release_date` when both formats are
2548
+ // emitted in the same close() pass. A per-format Date.now() would
2549
+ // cause the two bundles in bundles_by_format to disagree on
2550
+ // milliseconds.
2310
2551
  const issued = now;
2311
2552
  const productEntry = {
2312
2553
  '@id': productPurl,
@@ -2322,26 +2563,40 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2322
2563
  if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
2323
2564
  return fallback;
2324
2565
  };
2325
- // A: same `vex_status === 'fixed'` correctness rule as the
2326
- // CSAF emitter. The catalog `live_patch_available` flag is a global
2566
+ // Same `vex_status === 'fixed'` correctness rule as the CSAF
2567
+ // emitter. The catalog `live_patch_available` flag is a global
2327
2568
  // "vendor publishes a live-patch" signal, not an operator-host
2328
- // disposition. Treating it as `status: fixed` made OpenVEX statements
2329
- // claim resolution that the operator hadn't actually attested to.
2330
- // VEX consumers downstream of CISA / SBOM / supply-chain pipelines
2331
- // treat `fixed` as authoritative — emitting it without operator
2332
- // attestation is a downstream-misleading bug. Now the OpenVEX
2333
- // statement says `affected` (with action_statement pointing to the
2334
- // remediation, which may itself be the vendor live-patch route) unless
2335
- // the operator declared `vex_status: fixed` on the matched CVE.
2569
+ // disposition. Treating it as `status: fixed` would make OpenVEX
2570
+ // statements claim resolution the operator hadn't attested to. VEX
2571
+ // consumers downstream of CISA / SBOM / supply-chain pipelines treat
2572
+ // `fixed` as authoritative — emitting it without operator attestation
2573
+ // is a downstream-misleading bug. The OpenVEX statement says
2574
+ // `affected` (with action_statement pointing to the remediation,
2575
+ // which may itself be the vendor live-patch route) unless the
2576
+ // operator declared `vex_status: fixed` on the matched CVE.
2336
2577
  const cveStatements = analyze.matched_cves.map(c => {
2337
2578
  const stmt = {
2338
- vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
2579
+ vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
2339
2580
  products: [productEntry],
2340
2581
  timestamp: issued,
2341
2582
  impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
2342
2583
  };
2343
2584
  if (c.vex_status === 'fixed') {
2344
2585
  stmt.status = 'fixed';
2586
+ // OpenVEX 0.2.0 §4.1: `fixed` is an operator-attested resolution,
2587
+ // not a global vendor flag. Augment the impact_statement with an
2588
+ // evidence trail so downstream supply-chain consumers can chase
2589
+ // the attestation back to the operator's submitted evidence.
2590
+ // Short-hash is deterministic for the same (cve_id, signals)
2591
+ // input — re-emitting the bundle for the same submission yields
2592
+ // the same trail.
2593
+ const trailSrc = canonicalStringify({
2594
+ cve_id: c.cve_id,
2595
+ vex_status: 'fixed',
2596
+ signals: agentSignals && typeof agentSignals === 'object' ? agentSignals : {},
2597
+ });
2598
+ const shortHash = crypto.createHash('sha256').update(trailSrc).digest('hex').slice(0, 16);
2599
+ stmt.impact_statement = `${stmt.impact_statement} Operator verified fixed via evidence_hash=${shortHash}.`;
2345
2600
  } else {
2346
2601
  stmt.status = 'affected';
2347
2602
  stmt.action_statement = actionStatementFor(c.live_patch_available
@@ -2429,11 +2684,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2429
2684
  return { format: 'markdown', body: lines.join('\n') };
2430
2685
  }
2431
2686
 
2432
- // F16: pre-fix the fallback leaked raw analyze + validate internals
2433
- // (matched CVEs, framework gaps, residual-risk statements) under an
2434
- // arbitrary "format" name. Operators piping output to logging or
2435
- // third-party tooling could leak finding details just by typo'ing the
2436
- // format flag. Return the shape advertisement only.
2687
+ // The fallback must NOT leak raw analyze + validate internals (matched
2688
+ // CVEs, framework gaps, residual-risk statements) under an arbitrary
2689
+ // "format" name operators piping output to logging or third-party
2690
+ // tooling could leak finding details just by typo'ing the format flag.
2691
+ // Return the shape advertisement only.
2437
2692
  return {
2438
2693
  format,
2439
2694
  note: 'Unknown format',
@@ -2458,19 +2713,19 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2458
2713
  function normalizeSubmission(submission, playbook) {
2459
2714
  if (!submission || typeof submission !== "object") return submission || {};
2460
2715
 
2461
- // F15: signal_overrides must be a plain object. Pre-fix, a non-object
2462
- // value (string "foo", array [...]) was spread into out.signal_overrides
2463
- // via `{ ...(submission.signal_overrides || {}) }`. Spreading a string
2464
- // splatted it into { '0': 'f', '1': 'o', '2': 'o' }, which then
2465
- // confused detect()'s indicator-id lookup. Strip and log instead.
2716
+ // signal_overrides must be a plain object. Without this guard, a
2717
+ // non-object value (string "foo", array [...]) is spread into
2718
+ // out.signal_overrides via `{ ...(submission.signal_overrides || {}) }`
2719
+ // spreading a string splatters it into { '0': 'f', '1': 'o', '2': 'o' },
2720
+ // which confuses detect()'s indicator-id lookup. Strip and log instead.
2466
2721
  if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
2467
2722
  && (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
2468
2723
  if (!submission._runErrors) submission._runErrors = [];
2469
- submission._runErrors.push({
2724
+ pushRunError(submission._runErrors, {
2470
2725
  kind: 'signal_overrides_invalid',
2471
2726
  supplied_type: Array.isArray(submission.signal_overrides) ? 'array' : typeof submission.signal_overrides,
2472
2727
  reason: 'signal_overrides must be a plain object mapping indicator-id → verdict.'
2473
- });
2728
+ }, { dedupeKey: e => String(e.supplied_type) });
2474
2729
  submission = { ...submission, signal_overrides: {} };
2475
2730
  }
2476
2731
 
@@ -2495,13 +2750,13 @@ function normalizeSubmission(submission, playbook) {
2495
2750
  signals: { ...(submission.signals || {}) },
2496
2751
  precondition_checks: { ...(submission.precondition_checks || {}) },
2497
2752
  _original_shape: 'flat (v0.11.0)',
2498
- // BB P1-4: normalizeSubmission pushes structured errors (e.g.
2499
- // signal_overrides_invalid) onto submission._runErrors above. If the
2500
- // submission is flat, the fresh `out` literal built here loses that
2501
- // accumulator unless we forward it. run()'s harvest at the entry to
2502
- // detect/analyze reads agentSubmission._runErrors without this carry,
2503
- // flat submissions with invalid signal_overrides silently lost the
2504
- // v0.12.19 U REG-1 contract (errors never reached analyze.runtime_errors).
2753
+ // normalizeSubmission pushes structured errors (e.g.
2754
+ // signal_overrides_invalid) onto submission._runErrors above. For flat
2755
+ // submissions the fresh `out` literal built here loses that accumulator
2756
+ // unless we forward it; run()'s harvest at the entry to detect/analyze
2757
+ // reads agentSubmission._runErrors, so without the carry, flat
2758
+ // submissions with invalid signal_overrides drop the errors before
2759
+ // they can reach analyze.runtime_errors.
2505
2760
  ...(Array.isArray(submission._runErrors) && submission._runErrors.length
2506
2761
  ? { _runErrors: submission._runErrors.slice() }
2507
2762
  : {}),
@@ -2523,7 +2778,7 @@ function normalizeSubmission(submission, playbook) {
2523
2778
  // detect can emit `from_observation` on each indicator result. Diagnostic
2524
2779
  // value for operators chasing "which observation drove this verdict".
2525
2780
  //
2526
- // E9: when two observations target the same indicator id, last-write-wins
2781
+ // When two observations target the same indicator id, last-write-wins
2527
2782
  // silently. Track discards in _signal_origins_collisions so analyze can
2528
2783
  // surface analyze.signal_origins_with_collisions for batch evidence runs.
2529
2784
  out._signal_origins = out._signal_origins || {};
@@ -2605,7 +2860,7 @@ function autoDetectPreconditions(submission, playbook) {
2605
2860
  }
2606
2861
 
2607
2862
  function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2608
- // F7: catalog corruption surfaced at module-load now blocks runs cleanly.
2863
+ // Catalog corruption surfaced at module-load blocks runs cleanly.
2609
2864
  if (_xrefLoadError) {
2610
2865
  return {
2611
2866
  ok: false,
@@ -2619,7 +2874,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2619
2874
  try {
2620
2875
  playbook = loadPlaybook(playbookId);
2621
2876
  } catch (e) {
2622
- // F20: loadPlaybook failure → structured error (not crash).
2877
+ // loadPlaybook failure → structured error (not crash).
2623
2878
  return {
2624
2879
  ok: false,
2625
2880
  blocked_by: 'playbook_not_found',
@@ -2628,9 +2883,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2628
2883
  };
2629
2884
  }
2630
2885
 
2631
- // F8: validate directiveId before any phase runs. Unknown id used to throw
2632
- // inside analyze()/findDirective() uncaught, surfacing as a 500-style stack
2633
- // trace. Now returns a clean structured error with the valid directive list.
2886
+ // Validate directiveId before any phase runs. An unknown id would
2887
+ // otherwise throw inside analyze() / findDirective() uncaught, surfacing
2888
+ // as a 500-style stack trace; instead return a clean structured error
2889
+ // with the valid directive list.
2634
2890
  const validDirectives = (playbook.directives || []).map(d => d.id);
2635
2891
  if (!validDirectives.includes(directiveId)) {
2636
2892
  return {
@@ -2647,12 +2903,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2647
2903
  // / the host platform matches — the runner can answer those itself rather
2648
2904
  // than blocking on AI declaration.
2649
2905
  agentSubmission = normalizeSubmission(agentSubmission, playbook);
2650
- // F22: capture pre-autoDetect submission preconditions so we report
2906
+ // Capture pre-autoDetect submission preconditions so we report
2651
2907
  // user-declared provenance, not engine-auto-resolved values.
2652
2908
  const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
2653
2909
  agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
2654
2910
 
2655
- // F22: precondition_checks merge order is submission → runOpts (runOpts
2911
+ // precondition_checks merge order is submission → runOpts (runOpts
2656
2912
  // wins on collision). This is intentional: runOpts represents the most
2657
2913
  // recent caller intent (CLI flags / programmatic injection from a host
2658
2914
  // process), whereas submission was captured earlier during evidence
@@ -2680,38 +2936,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2680
2936
  // Cross-process mutex lock for this run. preflight verified no other lock
2681
2937
  // exists; we acquire ours and release in the finally block.
2682
2938
  const lockPath = acquireLock(playbookId);
2683
- // E12: parse the playbook once at run() entry and thread the parsed object
2684
- // through each phase via runOpts._playbookCache. Each phase otherwise calls
2685
- // loadPlaybook() independently; for a single run that's seven reads + parses
2686
- // of the same file. Cached version saves the redundant I/O + JSON parses.
2939
+ // Parse the playbook once at run() entry and thread the parsed object
2940
+ // through each phase via runOpts._playbookCache. Each phase otherwise
2941
+ // calls loadPlaybook() independently; for a single run that's seven
2942
+ // reads + parses of the same file. Caching saves the redundant I/O +
2943
+ // JSON parses.
2687
2944
  //
2688
- // F2/F9: session_id generated ONCE here, threaded into close() via
2689
- // cachedRunOpts.session_id. Pre-fix close() generated its own session_id
2690
- // independently, so CSAF tracking.id / OpenVEX @id / product PURLs all
2691
- // diverged from the run()-returned session_id and the on-disk attestation
2692
- // file name. Operators correlating attestation files to embedded bundle
2693
- // URNs got mismatched ids.
2945
+ // session_id is generated ONCE here and threaded into close() via
2946
+ // cachedRunOpts.session_id so CSAF tracking.id / OpenVEX @id / product
2947
+ // PURLs / on-disk attestation filenames all share one identifier.
2948
+ // Without the single-source-of-truth, close() would mint its own id
2949
+ // and operators correlating attestation files to embedded bundle URNs
2950
+ // would see mismatches.
2694
2951
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
2695
2952
  const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
2696
- // E3: run-time error accumulator for evalCondition regex failures and other
2953
+ // Run-time error accumulator for evalCondition regex failures and other
2697
2954
  // non-fatal anomalies surfaced into analyze.runtime_errors[].
2698
2955
  const runErrors = [];
2699
2956
  cachedRunOpts._runErrors = runErrors;
2700
- // U REG-1: normalizeSubmission may push structured errors (e.g.
2701
- // signal_overrides_invalid) onto submission._runErrors. Pre-fix these were
2702
- // stranded — they never reached the run-level accumulator that analyze()
2703
- // slices into runtime_errors[], so F20's "analyze surfaces all runtime
2704
- // errors" contract was silently broken. Splice the pre-run errors into
2705
- // the run-level accumulator and strip the field off the submission so it
2706
- // doesn't pollute the F1 evidence_hash digest (the hash canonicalizes the
2707
- // submission and a non-deterministic _runErrors would change it).
2957
+ // normalizeSubmission may push structured errors (e.g.
2958
+ // signal_overrides_invalid) onto submission._runErrors. Splice them
2959
+ // into the run-level accumulator so analyze.runtime_errors[] surfaces
2960
+ // them, and strip the field off the submission so it doesn't pollute
2961
+ // the evidence_hash digest (the hash canonicalizes the submission and
2962
+ // a non-deterministic _runErrors would change it).
2708
2963
  if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
2709
2964
  runErrors.push(...agentSubmission._runErrors);
2710
2965
  }
2711
2966
  if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
2712
2967
  delete agentSubmission._runErrors;
2713
2968
  }
2714
- // E6: phases the runner should SKIP execution for, based on skip_phase
2969
+ // Phases the runner should SKIP execution for, based on skip_phase
2715
2970
  // preconditions surfaced in preflight.issues.
2716
2971
  const skipPhases = new Set();
2717
2972
  for (const issue of (pre.issues || [])) {
@@ -2753,28 +3008,37 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2753
3008
  phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
2754
3009
  phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
2755
3010
 
2756
- // E3: analyze() already sliced runOpts._runErrors into
3011
+ // analyze() already sliced runOpts._runErrors into
2757
3012
  // phases.analyze.runtime_errors at return time. Validate + close may
2758
3013
  // have pushed additional regex errors AFTER analyze returned; surface
2759
3014
  // those onto phases.analyze.runtime_errors so the field reflects every
2760
3015
  // regex failure in the run. De-dupe by JSON shape so the analyze-time
2761
3016
  // snapshot doesn't double-count.
2762
3017
  if (runErrors.length && phases.analyze) {
2763
- const existing = new Set((phases.analyze.runtime_errors || []).map(e => JSON.stringify(e)));
2764
- const additions = runErrors.filter(e => !existing.has(JSON.stringify(e)));
3018
+ // `_truncated` sentinels are pushed by pushRunError when a per-kind
3019
+ // or total cap fires. They aggregate via in-place `dropped` increments,
3020
+ // so the same sentinel object is BOTH in the analyze snapshot AND in
3021
+ // the late-push `runErrors` ref. Skip them on the dedupe-merge pass
3022
+ // to keep the snapshot's authoritative dropped-count, rather than
3023
+ // double-stamping a second sentinel with the same `dropped` value.
3024
+ const existing = new Set(
3025
+ (phases.analyze.runtime_errors || [])
3026
+ .filter(e => !(e && e.kind === '_truncated'))
3027
+ .map(e => JSON.stringify(e))
3028
+ );
3029
+ const additions = runErrors.filter(e => !(e && e.kind === '_truncated') && !existing.has(JSON.stringify(e)));
2765
3030
  if (additions.length) {
2766
3031
  phases.analyze.runtime_errors = (phases.analyze.runtime_errors || []).concat(additions);
2767
3032
  }
2768
3033
  }
2769
3034
 
2770
- // F1: evidence_hash binds the operator's submission to the verdict.
2771
- // Pre-fix the hash only covered { playbook, directive, cves, rwep,
2772
- // classification }two operators submitting completely different
2773
- // evidence that happened to produce the same classification got the
2774
- // same evidence_hash, breaking the contract that the hash uniquely
2775
- // identifies a run. Now the hash includes a canonicalized SHA-256 over
2776
- // the submission (observations, signal_overrides, signals) with sorted
2777
- // keys recursively. `captured_at` and other timestamp-like fields are
3035
+ // evidence_hash binds the operator's submission to the verdict. The
3036
+ // hash must include the canonicalized submission (observations,
3037
+ // signal_overrides, signals)keying it on only { playbook, directive,
3038
+ // cves, rwep, classification } would let two operators with completely
3039
+ // different evidence collide on the same hash whenever their
3040
+ // classifications match. Use SHA-256 over the recursively sorted
3041
+ // submission. `captured_at` and other timestamp-like fields are
2778
3042
  // INTENTIONALLY excluded so that re-running with the same submission
2779
3043
  // produces the same hash — `reattest` relies on this to detect drift
2780
3044
  // (different submission → different hash → drift exists).
@@ -2799,7 +3063,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2799
3063
  evidence_hash: evidenceHash,
2800
3064
  submission_digest: submissionDigest,
2801
3065
  preflight_issues: pre.issues,
2802
- // F22: source provenance for precondition_checks. Shape:
3066
+ // Source provenance for precondition_checks. Shape:
2803
3067
  // { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
2804
3068
  precondition_check_source: pcSource,
2805
3069
  phases
@@ -2813,7 +3077,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
2813
3077
  // --- helpers ---
2814
3078
 
2815
3079
  /**
2816
- * F1: deterministic JSON stringification with recursively sorted keys.
3080
+ * Deterministic JSON stringification with recursively sorted keys.
2817
3081
  * Without sorted keys two semantically identical submissions ({a:1, b:2}
2818
3082
  * vs {b:2, a:1}) would hash to different digests, breaking reattest's
2819
3083
  * "same submission → same hash" contract. Arrays preserve order
@@ -2829,7 +3093,7 @@ function canonicalStringify(v) {
2829
3093
  }
2830
3094
 
2831
3095
  /**
2832
- * F1: pick the operator-meaningful fields out of the normalized submission
3096
+ * Pick the operator-meaningful fields out of the normalized submission
2833
3097
  * for hashing. captured_at, _signal_origins, _signal_origins_collisions,
2834
3098
  * and _original_shape are intentionally excluded — they're either
2835
3099
  * timestamps (would break "same submission → same hash") or runner-internal
@@ -2936,7 +3200,7 @@ function evalCondition(expr, ctx, playbook) {
2936
3200
  if (m) {
2937
3201
  const val = resolvePath(ctx, m[1]);
2938
3202
  if (typeof val !== 'string') return false;
2939
- // E3: an operator-supplied or playbook-supplied regex with a syntax bug
3203
+ // An operator-supplied or playbook-supplied regex with a syntax bug
2940
3204
  // (or pathological backtracking) must NOT crash the engine mid-analyze.
2941
3205
  // Catch construction + test exceptions, return false, and push a
2942
3206
  // structured _regex_eval_error into ctx._runErrors (when present) so
@@ -2949,8 +3213,19 @@ function evalCondition(expr, ctx, playbook) {
2949
3213
  // Two sites where ctx may carry an accumulator: runOpts._runErrors
2950
3214
  // (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
2951
3215
  // form; fall back to ctx.
2952
- if (ctx && Array.isArray(ctx._runErrors)) ctx._runErrors.push(errorRec);
2953
- else if (playbook && Array.isArray(playbook._runErrors)) playbook._runErrors.push(errorRec);
3216
+ // Tag with a `kind` so pushRunError can apply per-kind cap + dedupe
3217
+ // (same source+expr regex error firing N times per playbook would
3218
+ // otherwise spam runtime_errors). The original `_regex_eval_error`
3219
+ // payload is preserved for backward compatibility.
3220
+ const taggedErr = { kind: 'regex_eval_error', ..._regexErrorPayload(errorRec) };
3221
+ const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
3222
+ : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
3223
+ : null;
3224
+ if (target) {
3225
+ pushRunError(target, taggedErr, {
3226
+ dedupeKey: x => `${x.source || ''}::${x.expr || ''}`,
3227
+ });
3228
+ }
2954
3229
  return false;
2955
3230
  }
2956
3231
  }
@@ -3015,12 +3290,11 @@ function stripOuterParens(expr) {
3015
3290
  * submits clock_started_at_<event> ISO strings as it progresses through
3016
3291
  * incident-response milestones.
3017
3292
  *
3018
- * E7: per AGENTS.md Phase 7, the legal contract is that the clock starts
3293
+ * Per AGENTS.md Phase 7, the legal contract is that the clock starts
3019
3294
  * from OPERATOR AWARENESS — not from the moment the engine emits a
3020
- * `detected` classification. Pre-fix, this auto-stamped Date.now() on
3021
- * detect_confirmed whenever the engine classified as detected, which is
3022
- * incorrect: the operator may not have seen the result yet. The corrected
3023
- * semantics:
3295
+ * `detected` classification. Auto-stamping Date.now() on detect_confirmed
3296
+ * whenever the engine classifies as detected would be incorrect: the
3297
+ * operator may not have seen the result yet. Semantics:
3024
3298
  *
3025
3299
  * - If the agent explicitly submits clock_started_at_<event>: use it.
3026
3300
  * - Otherwise, for 'detect_confirmed' with classification='detected':
@@ -3123,9 +3397,9 @@ module.exports = {
3123
3397
  vexFilterFromDoc,
3124
3398
  normalizeSubmission,
3125
3399
  autoDetectPreconditions,
3126
- // MM P1-D: exposed for tests/audit-vv-trust-fixes.test.js so library-side
3127
- // direct callers (the fallback path the CLI guard cannot reach) can be
3128
- // exercised without spawning a CLI subprocess.
3400
+ // Exported so library-side direct callers (the fallback path the CLI
3401
+ // guard cannot reach) can be exercised without spawning a CLI
3402
+ // subprocess.
3129
3403
  sanitizeOperatorText,
3130
3404
  // internal helpers exposed for tests
3131
3405
  _resolvedPhase: resolvedPhase,