@blamejs/exceptd-skills 0.19.33 → 0.19.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +2 -2
  4. package/lib/auto-discovery.js +56 -286
  5. package/lib/canonical-eq.js +7 -40
  6. package/lib/citation-resolve.js +22 -70
  7. package/lib/collectors/ai-api.js +20 -54
  8. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  9. package/lib/collectors/citation-hygiene.js +72 -210
  10. package/lib/collectors/containers.js +41 -130
  11. package/lib/collectors/cred-stores.js +31 -115
  12. package/lib/collectors/crypto-codebase.js +55 -138
  13. package/lib/collectors/crypto.js +24 -54
  14. package/lib/collectors/hardening.js +20 -78
  15. package/lib/collectors/kernel.js +16 -46
  16. package/lib/collectors/library-author.js +57 -206
  17. package/lib/collectors/mcp.js +24 -70
  18. package/lib/collectors/runtime.js +24 -86
  19. package/lib/collectors/sbom.js +34 -106
  20. package/lib/collectors/scan-excludes.js +31 -138
  21. package/lib/collectors/secrets.js +62 -178
  22. package/lib/cross-ref-api.js +39 -123
  23. package/lib/currency-severity.js +8 -27
  24. package/lib/cve-batch.js +13 -21
  25. package/lib/cve-cli.js +13 -20
  26. package/lib/cve-curation.js +72 -239
  27. package/lib/cve-regression-watcher.js +29 -152
  28. package/lib/cvss.js +13 -54
  29. package/lib/doctor-bucketing.js +3 -19
  30. package/lib/exit-codes.js +10 -42
  31. package/lib/flag-suggest.js +7 -25
  32. package/lib/framework-gap.js +35 -114
  33. package/lib/gap-detectors.js +37 -159
  34. package/lib/id-validation.js +9 -30
  35. package/lib/job-queue.js +13 -36
  36. package/lib/lint-skills.js +64 -232
  37. package/lib/playbook-runner.js +693 -2095
  38. package/lib/prefetch.js +100 -376
  39. package/lib/refresh-external.js +199 -627
  40. package/lib/refresh-network.js +75 -307
  41. package/lib/rfc-cli.js +23 -68
  42. package/lib/scoring.js +77 -145
  43. package/lib/sign.js +43 -229
  44. package/lib/source-advisories.js +43 -194
  45. package/lib/source-ghsa.js +37 -120
  46. package/lib/source-osv.js +94 -266
  47. package/lib/ttp-mapper.js +14 -24
  48. package/lib/upstream-check-cli.js +10 -28
  49. package/lib/upstream-check.js +19 -44
  50. package/lib/validate-catalog-meta.js +17 -61
  51. package/lib/validate-cve-catalog.js +43 -119
  52. package/lib/validate-indexes.js +25 -76
  53. package/lib/validate-package.js +16 -62
  54. package/lib/validate-playbooks.js +69 -275
  55. package/lib/validate-vendor.js +16 -49
  56. package/lib/verify.js +56 -286
  57. package/lib/version-pins.js +5 -34
  58. package/lib/worker-pool.js +11 -30
  59. package/lib/xml-tokenizer.js +47 -152
  60. package/manifest.json +53 -53
  61. package/orchestrator/dispatcher.js +17 -68
  62. package/orchestrator/event-bus.js +11 -74
  63. package/orchestrator/index.js +138 -412
  64. package/orchestrator/pipeline.js +28 -85
  65. package/orchestrator/scanner.js +34 -138
  66. package/orchestrator/scheduler.js +20 -84
  67. package/package.json +1 -1
  68. package/sbom.cdx.json +241 -241
  69. package/scripts/audit-catalog-gaps.js +9 -62
  70. package/scripts/audit-cross-skill.js +5 -31
  71. package/scripts/audit-perf.js +6 -16
  72. package/scripts/backfill-theater-test.js +7 -64
  73. package/scripts/bootstrap.js +12 -44
  74. package/scripts/build-indexes.js +40 -154
  75. package/scripts/builders/activity-feed.js +4 -14
  76. package/scripts/builders/catalog-summaries.js +3 -10
  77. package/scripts/builders/currency.js +7 -20
  78. package/scripts/builders/cwe-chains.js +7 -30
  79. package/scripts/builders/did-ladders.js +6 -13
  80. package/scripts/builders/frequency.js +5 -19
  81. package/scripts/builders/jurisdiction-clocks.js +6 -25
  82. package/scripts/builders/recipes.js +6 -14
  83. package/scripts/builders/section-offsets.js +13 -51
  84. package/scripts/builders/stale-content.js +7 -28
  85. package/scripts/builders/summary-cards.js +8 -29
  86. package/scripts/builders/theater-fingerprints.js +12 -27
  87. package/scripts/builders/token-budget.js +4 -31
  88. package/scripts/check-agents-md-collectors.js +11 -54
  89. package/scripts/check-catalog-gap-budget.js +15 -32
  90. package/scripts/check-changelog-extract.js +18 -48
  91. package/scripts/check-codebase-patterns-currency.js +6 -22
  92. package/scripts/check-codebase-patterns.js +50 -143
  93. package/scripts/check-epss-consistency.js +9 -64
  94. package/scripts/check-framework-gap-coverage.js +13 -31
  95. package/scripts/check-manifest-snapshot.js +13 -73
  96. package/scripts/check-sbom-currency.js +44 -142
  97. package/scripts/check-test-count.js +15 -52
  98. package/scripts/check-test-coverage.js +66 -197
  99. package/scripts/check-test-subjects.js +21 -62
  100. package/scripts/check-ttp-references.js +14 -38
  101. package/scripts/check-ttp-upstream.js +8 -40
  102. package/scripts/check-version-bump.js +9 -61
  103. package/scripts/check-version-tags.js +20 -121
  104. package/scripts/predeploy.js +38 -184
  105. package/scripts/refresh-manifest-snapshot.js +16 -38
  106. package/scripts/refresh-mitre-atlas.js +3 -8
  107. package/scripts/refresh-mitre-attack.js +1 -8
  108. package/scripts/refresh-mitre-d3fend.js +3 -9
  109. package/scripts/refresh-mitre-ics-attack.js +3 -8
  110. package/scripts/refresh-reverse-refs.js +27 -94
  111. package/scripts/refresh-rfc-index.js +2 -10
  112. package/scripts/refresh-sbom.js +31 -161
  113. package/scripts/refresh-upstream-catalogs.js +40 -137
  114. package/scripts/release.js +69 -232
  115. package/scripts/run-e2e-scenarios.js +24 -71
  116. package/scripts/sync-manifest-metadata.js +10 -34
  117. package/scripts/sync-package-description.js +8 -17
  118. package/scripts/validate-vendor-online.js +13 -44
  119. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,46 +1,9 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Playbook runner — executes the seven-phase investigation contract defined in
5
- * lib/schemas/playbook.schema.json:
6
- *
7
- * 1. govern exceptd. Loads GRC context: jurisdiction obligations, theater
8
- * fingerprints, framework gaps, skills to preload. Sets the
9
- * compliance lens before any investigation runs.
10
- * 2. direct exceptd. Scopes the investigation: threat context with current
11
- * CVE/TTP citations, RWEP thresholds, framework lag declaration,
12
- * skill chain, token budget.
13
- * 3. look host AI. Collects typed artifacts (logs/files/processes/
14
- * network/etc.) per artifact spec, with air-gap fallbacks.
15
- * 4. detect host AI. Evaluates artifacts against typed indicators, applies
16
- * false-positive profile, classifies as detected | inconclusive
17
- * | not_detected.
18
- * 5. analyze exceptd. Computes RWEP from rwep_inputs, scores blast radius,
19
- * runs compliance_theater_check, generates framework_gap_mapping
20
- * entries, fires escalation_criteria.
21
- * 6. validate exceptd. Picks remediation_path by priority + preconditions,
22
- * emits validation_tests, renders residual_risk_statement, lists
23
- * evidence_requirements, computes regression schedule.
24
- * 7. close exceptd. Closes the GRC loop: assembles evidence_package
25
- * (signed by default), drafts learning_loop lesson, computes
26
- * notification_actions deadlines from govern.jurisdiction_obligations
27
- * clock_starts + window_hours, evaluates exception_generation
28
- * trigger and renders auditor-ready language, finalizes
29
- * regression_schedule.next_run.
30
- *
31
- * Currency gate: _meta.threat_currency_score < 50 hard-blocks execution unless
32
- * the caller passes { forceStale: true }. Below 70 warns. The schema declares
33
- * the score; the runner enforces.
34
- *
35
- * Preconditions: each _meta.preconditions entry has on_fail = halt|warn|skip_phase.
36
- * Engine evaluates the (host AI-supplied) check value and reacts accordingly.
37
- *
38
- * Mutex: an in-process Set tracks active playbook runs. Engine refuses to start
39
- * a playbook whose _meta.mutex intersects active runs.
40
- *
41
- * feeds_into: close() returns a list of downstream playbook IDs whose
42
- * conditions are satisfied by this run's finding — the agent decides whether
43
- * to chain into them.
4
+ * Playbook runner — executes the seven-phase contract declared in
5
+ * lib/schemas/playbook.schema.json. The engine owns govern, direct, analyze,
6
+ * validate and close; the host AI owns look and detect.
44
7
  */
45
8
 
46
9
  const fs = require('fs');
@@ -51,15 +14,8 @@ const scoring = require('./scoring');
51
14
  const { assertIdComponent } = require('./id-validation');
52
15
  const codepointClass = require('../vendor/blamejs/codepoint-class.js');
53
16
 
54
- // cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
55
- // JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
56
- // failure, returns an empty stub, and accumulates the error in
57
- // getLoadErrors(). run() probes for accumulated load errors and returns
58
- // a structured `blocked_by:'catalog_corrupt'` rather than letting analyze
59
- // silently operate against an empty catalog. Note: the call to
60
- // xref.byCve below force-touches the catalog so the load error surfaces
61
- // at module load (it's lazy otherwise), which gives run() a deterministic
62
- // signal regardless of submission shape.
17
+ // cross-ref-api swallows a corrupt cve-catalog.json into an empty stub and
18
+ // records the failure in getLoadErrors(), which run() probes before analyze.
63
19
  let xref;
64
20
  let _xrefLoadError = null;
65
21
  let _xrefProbed = false;
@@ -74,13 +30,7 @@ try {
74
30
  _xrefProbed = true; // require itself failed; nothing left to probe
75
31
  }
76
32
 
77
- // Probe the catalog (parse it, surface any load error) LAZILY on first need
78
- // rather than at module load. The probe parses the ~2.6MB CVE catalog (~8.5ms);
79
- // doing it eagerly charged that to every cheap verb (brief/ask/lint/
80
- // discover) that never analyzes. run() calls this before the analyze path, so
81
- // a corrupt catalog still surfaces as blocked_by:'catalog_corrupt' before
82
- // analyze — just not on verbs that don't touch the catalog. Memoized: probes
83
- // at most once per process.
33
+ // Lazy and memoized: only a verb that analyzes pays the ~2.6MB catalog parse.
84
34
  function getXrefLoadError() {
85
35
  if (_xrefProbed) return _xrefLoadError;
86
36
  _xrefProbed = true;
@@ -101,24 +51,13 @@ function getXrefLoadError() {
101
51
  const ROOT = path.join(__dirname, '..');
102
52
  const PLAYBOOK_DIR = process.env.EXCEPTD_PLAYBOOK_DIR || path.join(ROOT, 'data', 'playbooks');
103
53
 
104
- // In-process mutex tracker. Survives only the current Node process.
105
- // Persistent cross-process coordination is out of scope — that's for the GRC
106
- // platform integration, not the runner.
54
+ // In-process mutex tracker; survives only the current Node process.
107
55
  const _activeRuns = new Set();
108
56
 
109
- // Bounded push into a runtime_errors array with per-kind caps, optional
110
- // per-kind dedupe, and a total cap. A long-running detect/analyze loop that
111
- // rejects a malformed catalog entry on every iteration would otherwise let
112
- // runtime_errors grow unbounded and balloon the bundle output. When the cap
113
- // fires the helper records a `_truncated` sentinel so downstream consumers
114
- // see the drop without needing to compare cardinalities.
115
- //
116
- // opts.cap per-kind cap (default 100)
117
- // opts.totalCap total array cap (default 1000)
118
- // opts.dedupeKey optional fn(entry) returning a string key. When supplied,
119
- // a push with the same (kind, dedupeKey) tuple is skipped.
120
- //
121
- // Returns true if the entry was pushed, false otherwise (capped or deduped).
57
+ // Bounded push into a runtime_errors array: opts.cap (100) per kind,
58
+ // opts.totalCap (1000) overall, opts.dedupeKey(entry) collapsing same-(kind,
59
+ // key) pushes. A capped push records a `_truncated` sentinel instead, and
60
+ // returns false — true means the entry was pushed.
122
61
  function pushRunError(arr, entry, opts) {
123
62
  if (!Array.isArray(arr) || !entry || typeof entry !== 'object') return false;
124
63
  opts = opts || {};
@@ -149,10 +88,7 @@ function pushRunError(arr, entry, opts) {
149
88
  return true;
150
89
  }
151
90
 
152
- // Unwrap a legacy `{ _regex_eval_error: { source, expr, message } }` record
153
- // into the flat fields pushRunError dedupes on. Used by evalCondition()'s
154
- // regex-failure path so per-(source, expr) duplicates collapse to one entry
155
- // plus a `_truncated` sentinel when the cap fires.
91
+ // Flatten a `_regex_eval_error` record into the fields pushRunError dedupes on.
156
92
  function _regexErrorPayload(rec) {
157
93
  if (rec && typeof rec === 'object' && rec._regex_eval_error) {
158
94
  const { source, expr, message } = rec._regex_eval_error;
@@ -161,8 +97,6 @@ function _regexErrorPayload(rec) {
161
97
  return { _regex_eval_error: rec };
162
98
  }
163
99
 
164
- // --- catalog access ---
165
-
166
100
  function listPlaybooks() {
167
101
  if (!fs.existsSync(PLAYBOOK_DIR)) return [];
168
102
  return fs.readdirSync(PLAYBOOK_DIR)
@@ -171,26 +105,20 @@ function listPlaybooks() {
171
105
  }
172
106
 
173
107
  function loadPlaybook(playbookId) {
174
- // Traversal defense co-located with the path.join — every caller gets it,
175
- // not just the CLI dispatcher's pre-validation wrapper.
108
+ // Traversal defense sits with the path.join so every caller gets it, not
109
+ // only the CLI dispatcher.
176
110
  assertIdComponent(playbookId, 'playbook');
177
111
  const p = path.join(PLAYBOOK_DIR, `${playbookId}.json`);
178
112
  if (!fs.existsSync(p)) throw new Error(`Playbook not found: ${playbookId} (expected ${p})`);
179
113
  return JSON.parse(fs.readFileSync(p, 'utf8'));
180
114
  }
181
115
 
182
- // Per-run playbook cache. Each phase function reads runOpts._playbookCache
183
- // before falling back to loadPlaybook(). run() sets _playbookCache once at
184
- // entry so seven phases share one disk read + JSON parse instead of seven.
185
-
186
116
  function findDirective(playbook, directiveId) {
187
117
  const d = playbook.directives.find(x => x.id === directiveId);
188
118
  if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
189
119
  return d;
190
120
  }
191
121
 
192
- // --- phase-resolution: merge playbook.phases with directive.phase_overrides ---
193
-
194
122
  function resolvedPhase(playbook, directiveId, phaseName) {
195
123
  const base = playbook.phases[phaseName] || {};
196
124
  const directive = playbook.directives.find(x => x.id === directiveId);
@@ -204,55 +132,28 @@ function deepMerge(a, b) {
204
132
  if (typeof b !== 'object' || Array.isArray(b)) return b;
205
133
  const out = { ...a };
206
134
  for (const [k, v] of Object.entries(b)) {
207
- // Skip prototype-polluting keys. phase_overrides come from the Ed25519-
208
- // signed catalog today, but deepMerge is an exported utility (_deepMerge)
209
- // and must never rebind a prototype if reused with parsed/operator input —
210
- // same guard as the precondition_checks merge below. `out[k]=` on
211
- // k='__proto__' would invoke the prototype-rebinding setter.
135
+ // `out[k]=` on k='__proto__' invokes the prototype-rebinding setter, and
136
+ // deepMerge is exported (_deepMerge) so it may see parsed operator input.
212
137
  if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
213
138
  out[k] = (k in out) ? deepMerge(out[k], v) : v;
214
139
  }
215
140
  return out;
216
141
  }
217
142
 
218
- // --- pre-flight: currency + preconditions + mutex ---
219
-
220
143
  /**
221
- * Pre-flight gate. Three concerns:
222
- *
223
- * 1. Currency. threat_currency_score < 50 hard-blocks unless
224
- * runOpts.forceStale=true. < 70 emits a warning issue.
225
- * 2. Preconditions. _meta.preconditions[] entries with on_fail in
226
- * {halt, warn, skip_phase} are evaluated against
227
- * runOpts.precondition_checks[id]. Missing values → precondition_unverified
228
- * issue (plus halt if on_fail=halt). False values → precondition_warn or
229
- * precondition_skip per on_fail.
230
- * 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
231
- * AND with the filesystem lockfile dir blocks the run.
232
- *
233
- * When runOpts.strictPreconditions === true, warn-level outcomes
234
- * (precondition_warn, precondition_unverified with on_fail=warn or
235
- * skip_phase) are ESCALATED to halts. The function returns ok:false
236
- * with blocked_by='precondition' and an issues array containing
237
- * precondition_halt entries. Callers wanting "CI gate: any unverified
238
- * precondition is a failure" pass strictPreconditions=true.
239
- *
240
- * When a precondition with on_fail='skip_phase' fails, the issue carries
241
- * skip_phase: 'detect' (default) so run() can route to a skipped-phase
242
- * placeholder rather than executing detect against a missing
243
- * prerequisite.
144
+ * Pre-flight gate over currency, preconditions and mutex.
145
+ * runOpts.strictPreconditions escalates every warn-level precondition outcome
146
+ * to a halt; an on_fail='skip_phase' failure instead emits an issue carrying
147
+ * skip_phase (default 'detect'), which run() routes to a skipped placeholder.
244
148
  */
245
149
  function preflight(playbook, runOpts = {}) {
246
150
  const issues = [];
247
151
  const meta = playbook._meta;
248
152
  const strict = runOpts.strictPreconditions === true;
249
153
 
250
- // 1. Currency gate
251
154
  const score = meta.threat_currency_score;
252
- // A non-numeric score (absent / null / NaN from a malformed _meta) must HARD
253
- // BLOCK, not slip through: `undefined < 50` is false, which would silently
254
- // bypass the staleness gate on exactly the playbooks whose currency metadata
255
- // is broken. Treat "no usable score" as the most-stale state.
155
+ // No usable score is the most-stale state: `undefined < 50` is false, which
156
+ // would bypass the gate on exactly the broken metadata.
256
157
  const scoreUsable = typeof score === 'number' && !Number.isNaN(score);
257
158
  if ((!scoreUsable || score < 50) && !runOpts.forceStale) {
258
159
  return {
@@ -268,14 +169,11 @@ function preflight(playbook, runOpts = {}) {
268
169
  issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
269
170
  }
270
171
 
271
- // 2. Preconditions
272
172
  for (const pc of meta.preconditions || []) {
273
173
  const submitted = runOpts.precondition_checks?.[pc.id];
274
174
  if (submitted === undefined) {
275
175
  const submission_hint = `Submit precondition_checks in your evidence JSON, e.g. { "precondition_checks": { "${pc.id}": true } }. Pass via --evidence <file.json> or pipe to stdin with --evidence -. The runner lifts precondition_checks into runOpts before the gate evaluates.`;
276
176
  if (strict) {
277
- // strictPreconditions promotes unverified to halt regardless of
278
- // declared on_fail.
279
177
  issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
280
178
  return {
281
179
  ok: false,
@@ -303,15 +201,13 @@ function preflight(playbook, runOpts = {}) {
303
201
  ok: false,
304
202
  blocked_by: 'precondition',
305
203
  reason: `Precondition ${pc.id} failed: ${pc.description}`,
306
- // Explicit-false halts carry a specific remediation so the renderer
307
- // prefers it over the generic platform-gate hint — an intent/ownership
308
- // gate (e.g. operator-owns-ci-fleet) is not a platform mismatch.
204
+ // Its own remediation, so the renderer prefers it to the generic
205
+ // platform-gate hint: an ownership gate is not a platform mismatch.
309
206
  remediation: `Precondition ${pc.id} was submitted as false: ${pc.description} Attest it as true and re-run — submit precondition_checks {"${pc.id}": true} in your evidence JSON, or pass the owning collector's attestation flag at collect time (for example: collect cicd-pipeline-compromise --attest-ownership).`,
310
207
  issues
311
208
  };
312
209
  }
313
210
  if (strict) {
314
- // Warn-level + skip_phase outcomes escalate to halt under strict.
315
211
  issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
316
212
  return {
317
213
  ok: false,
@@ -321,10 +217,6 @@ function preflight(playbook, runOpts = {}) {
321
217
  };
322
218
  }
323
219
  if (pc.on_fail === 'skip_phase') {
324
- // Emit a skip_phase field so run() can route to a skipped-phase
325
- // placeholder. Default target phase is 'detect' (the most common
326
- // skip target — preconditions typically gate host-side detection).
327
- // Playbooks may override via pc.skip_phase.
328
220
  issues.push({ kind: 'precondition_skip', id: pc.id, message: pc.description, skip_phase: pc.skip_phase || 'detect' });
329
221
  } else {
330
222
  issues.push({ kind: 'precondition_warn', id: pc.id, message: pc.description });
@@ -332,17 +224,14 @@ function preflight(playbook, runOpts = {}) {
332
224
  }
333
225
  }
334
226
 
335
- // 3. Mutex — both intra-process (in-memory Set) AND cross-process
336
- // (filesystem lockfile under .exceptd/locks/<playbook>.lock). v0.11.0 only
337
- // enforced intra-process; v0.11.1 adds cross-process so two parallel CLI
338
- // invocations of mutex-conflicting playbooks correctly race-detect.
227
+ // Both the in-process Set and the lockfile, so two parallel CLI invocations
228
+ // of conflicting playbooks race-detect.
339
229
  for (const conflictId of meta.mutex || []) {
340
230
  if (_activeRuns.has(conflictId)) {
341
231
  return { ok: false, blocked_by: 'mutex', reason: `Mutex conflict (intra-process): playbook ${conflictId} is currently active and listed in this playbook's mutex set.`, issues };
342
232
  }
343
233
  const lockPath = lockFilePath(conflictId);
344
234
  if (lockPath && fs.existsSync(lockPath)) {
345
- // Stale-lock detection: if the recorded PID is dead, ignore the lock.
346
235
  try {
347
236
  const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
348
237
  if (lock.pid && !pidAlive(lock.pid)) {
@@ -364,27 +253,10 @@ function preflight(playbook, runOpts = {}) {
364
253
  return { ok: true, issues };
365
254
  }
366
255
 
367
- // lockDir lives at a stable per-user path so two CLI invocations from
368
- // different working directories still share lock state for cross-process
369
- // mutex enforcement. A process.cwd()-relative dir would let invocations
370
- // from /tmp and from /home/user/project simultaneously each see an empty
371
- // locks dir and both run unchallenged.
372
- //
373
- // Resolution order (most-specific first):
374
- // 1. EXCEPTD_LOCK_DIR explicit container/CI override
375
- // 2. EXCEPTD_HOME || ~/.exceptd + /locks/<platform> per-user default
376
- // 3. os.tmpdir()/exceptd-locks-<platform> last-resort fallback
377
- // when the per-user home is non-writable (read-only home, restricted
378
- // sandbox/CI runner).
379
- //
380
- // The per-user home (mirroring the orchestrator's watch.lock + attestation
381
- // roots) is both the safer location — a per-user dir is not the
382
- // world-writable shared OS tempdir a preplant/symlink attack targets — and
383
- // the better fit for lockDir's stated goal: ~/.exceptd is per-user-stable
384
- // across working directories, whereas the shared tmpdir is the weaker
385
- // choice. The path keys on os.platform() so Windows/macOS/Linux locks live
386
- // under separate directories (avoids cross-platform stale-PID confusion when
387
- // a host is shared across OSes via networked FS).
256
+ // EXCEPTD_LOCK_DIR, then (EXCEPTD_HOME || ~/.exceptd)/locks/<platform>, then
257
+ // os.tmpdir() when the home is non-writable. The path must not depend on the
258
+ // working directory, or two invocations see different lock sets and both run
259
+ // unchallenged. The platform segment separates PIDs on a shared networked FS.
388
260
  function resolveLockDir() {
389
261
  if (process.env.EXCEPTD_LOCK_DIR) return process.env.EXCEPTD_LOCK_DIR;
390
262
  const home = process.env.EXCEPTD_HOME || (os.homedir() && path.join(os.homedir(), '.exceptd'));
@@ -392,10 +264,8 @@ function resolveLockDir() {
392
264
  const dir = path.join(home, 'locks', process.platform);
393
265
  try {
394
266
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
395
- // Probe writability with a marker; remove on success. A home that
396
- // mkdirs but can't be written (read-only mount, restrictive ACL) must
397
- // fall through to the tmpdir fallback rather than silently no-op every
398
- // lock write.
267
+ // A home that mkdirs but cannot be written must fall through to tmpdir
268
+ // rather than no-op every lock write.
399
269
  const probe = path.join(dir, `.write-probe-${process.pid}`);
400
270
  fs.writeFileSync(probe, '');
401
271
  fs.unlinkSync(probe);
@@ -407,9 +277,7 @@ function resolveLockDir() {
407
277
 
408
278
  function lockDir() {
409
279
  const dir = resolveLockDir();
410
- // Owner-only (0700): the mutex lock files inside use O_EXCL ('wx'), but
411
- // tightening the parent directory keeps another local user from listing or
412
- // tampering with this user's lock set.
280
+ // Owner-only: another local user must not list or tamper with this lock set.
413
281
  try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* exists / EACCES — non-fatal */ }
414
282
  return dir;
415
283
  }
@@ -419,24 +287,13 @@ function lockFilePath(playbookId) {
419
287
  catch { return null; }
420
288
  }
421
289
 
422
- // Same-PID stale-lockfile reclaim threshold. A same-process orphan (e.g.
423
- // an earlier run() that crashed without unlinking, or a try/catch that
424
- // swallowed the release) older than this is presumed dead and reclaimed.
425
- // 30s mirrors lib/refresh-external.js and lib/prefetch.js; long enough
426
- // that no legitimate playbook hold reaches it (govern/look/run phases
427
- // complete well inside one second per playbook), short enough that a
428
- // wedged process recovers within one CI step rather than the rest of its
429
- // lifetime.
290
+ // Same-PID stale-lockfile threshold, matching lib/refresh-external.js and
291
+ // lib/prefetch.js. No legitimate playbook hold reaches 30s.
430
292
  const STALE_LOCK_MS = 30_000;
431
293
 
432
- // Create the mutex lock file with an exclusive, owner-only descriptor
433
- // (O_CREAT | O_EXCL | 0o600 via openSync 'wx'). The lock-file NAME is
434
- // deliberately predictable — it IS the cross-process mutex, so every contender
435
- // must agree on it; security comes from the exclusive create (a preplanted file
436
- // or symlink makes the open throw EEXIST/EPERM, never a silent follow), the
437
- // 0o700 lock directory, and the 0o600 mode — written through openSync so the
438
- // secure-create primitive is explicit rather than a bare writeFileSync. Throws
439
- // EEXIST when the lock is already held, which every caller already handles.
294
+ // Exclusive owner-only create ('wx', 0o600). The file NAME is predictable by
295
+ // design — it IS the mutex — and safety comes from the exclusive create, which
296
+ // throws EEXIST/EPERM on a preplanted file or symlink rather than following it.
440
297
  function writeLockFile(p, playbookId) {
441
298
  const fd = fs.openSync(p, 'wx', 0o600);
442
299
  try {
@@ -454,14 +311,9 @@ function acquireLock(playbookId) {
454
311
  writePayload();
455
312
  return p;
456
313
  } catch (e) {
457
- // Stale-PID reclaim. Without it, a process that crashed mid-run
458
- // leaves its lockfile behind and every subsequent invocation runs
459
- // UNLOCKED. Mirror withCatalogLock's pattern: parse the recorded pid,
460
- // probe with `process.kill(pid, 0)`. ESRCH means the holder is dead —
461
- // unlink and retry once. EPERM (alive, different user) or any other
462
- // condition: leave the lock alone and return null with a diagnostic so
463
- // the caller knows acquisition failed because the lock is genuinely
464
- // held (not because the FS is broken or the playbook id is malformed).
314
+ // Stale-PID reclaim: without it a crashed process's lockfile leaves every
315
+ // later invocation running UNLOCKED. ESRCH means dead; EPERM means alive
316
+ // under another user, and the lock stands.
465
317
  if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
466
318
  try {
467
319
  const raw = fs.readFileSync(p, 'utf8');
@@ -475,15 +327,9 @@ function acquireLock(playbookId) {
475
327
  try { fs.unlinkSync(p); } catch {}
476
328
  try { writePayload(); return p; } catch { /* fall through */ }
477
329
  }
478
- // Same-PID stale-lockfile reclaim. If the recorded pid is ours,
479
- // the only way to escape an orphaned same-process lockfile is by
480
- // mtime. Do NOT blindly reclaim same-PID — legitimate reentrancy
481
- // (e.g. nested run() within one process) must still return null
482
- // so the caller knows the lock is held. A fresh same-PID lockfile
483
- // is reentrancy; one older than STALE_LOCK_MS is an orphan from
484
- // a crashed prior hold (or a try/catch that swallowed the release)
485
- // and must be reclaimed — otherwise the process can never acquire
486
- // this lock again for the rest of its lifetime.
330
+ // Same-PID reclaim goes by mtime: a fresh lockfile is legitimate
331
+ // reentrancy and stays held, while an older one is an orphan that
332
+ // would deny this process the lock for the rest of its lifetime.
487
333
  if (Number.isInteger(pid) && pid === process.pid) {
488
334
  try {
489
335
  const stat = fs.statSync(p);
@@ -495,19 +341,13 @@ function acquireLock(playbookId) {
495
341
  }
496
342
  } catch { /* unreadable lockfile — treat as held by a live process */ }
497
343
  }
498
- // Lock genuinely held (or filesystem error). Returning null keeps
499
- // back-compat with existing call sites that test `if (!lockPath)`.
500
- // Callers that want a clearer diagnostic should call
501
- // `acquireLockDiagnostic` instead.
344
+ // Null on failure — the contract every call site tests as `if (!lockPath)`.
502
345
  return null;
503
346
  }
504
347
  }
505
348
 
506
- // Callers needing to distinguish "couldn't acquire because the lock is
507
- // genuinely held by a live process" from "couldn't acquire because of an
508
- // unexpected error" can use this thin diagnostic wrapper.
509
- // Returns either { ok: true, path } or { ok: false, reason, lock_path?, holder_pid? }.
510
- // The bare `acquireLock` keeps its historical null-on-failure contract.
349
+ // acquireLock, but separating "held by a live process" from an unexpected
350
+ // error: { ok:true, path } or { ok:false, reason, lock_path?, holder_pid? }.
511
351
  function acquireLockDiagnostic(playbookId) {
512
352
  const p = lockFilePath(playbookId);
513
353
  if (!p) return { ok: false, reason: 'no_lock_path' };
@@ -534,10 +374,7 @@ function acquireLockDiagnostic(playbookId) {
534
374
  return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
535
375
  }
536
376
  }
537
- // Same-PID stale-lockfile reclaim (diagnostic variant). Same
538
- // semantics as in acquireLock: a same-process lockfile older than
539
- // STALE_LOCK_MS is an orphan and must be reclaimed; a fresher one
540
- // is legitimate reentrancy and stays held.
377
+ // Same-PID reclaim, same mtime semantics as acquireLock.
541
378
  if (Number.isInteger(pid) && pid === process.pid) {
542
379
  let mtimeMs = null;
543
380
  try { mtimeMs = fs.statSync(p).mtimeMs; } catch {}
@@ -569,20 +406,12 @@ function pidAlive(pid) {
569
406
  catch (e) { return e.code !== 'ESRCH'; }
570
407
  }
571
408
 
572
- // --- phase 1: govern ---
573
-
574
- /**
575
- * Load GRC context for the agent. Returns jurisdiction obligations (with
576
- * window_hours + clock_starts so close() can compute deadlines later), theater
577
- * fingerprints, framework gap summary, and skills to preload.
578
- */
409
+ // Phase 1. GRC context for the agent. Jurisdiction obligations carry
410
+ // window_hours + clock_starts because close() computes deadlines from them.
579
411
  function govern(playbookId, directiveId, runOpts = {}) {
580
412
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
581
413
  const g = resolvedPhase(playbook, directiveId, 'govern');
582
- // Sort jurisdiction obligations by window_hours ascending so the
583
- // tightest deadline (e.g. DORA's 4h, NIS2's 24h, GDPR's 72h) surfaces
584
- // first. Operators reading the govern output for ack-time briefing need
585
- // the most urgent clock at the top of the list.
414
+ // Ascending window_hours: the tightest clock heads the list.
586
415
  const obligations = (g.jurisdiction_obligations || []).slice().sort((a, b) => {
587
416
  const aw = (a && typeof a.window_hours === 'number') ? a.window_hours : Number.POSITIVE_INFINITY;
588
417
  const bw = (b && typeof b.window_hours === 'number') ? b.window_hours : Number.POSITIVE_INFINITY;
@@ -600,16 +429,11 @@ function govern(playbookId, directiveId, runOpts = {}) {
600
429
  theater_fingerprints: g.theater_fingerprints || [],
601
430
  framework_context: g.framework_context || {},
602
431
  skill_preload: g.skill_preload || [],
603
- // v0.11.12 (#124): --ack belongs semantically in govern (it acknowledges
604
- // the jurisdiction_obligations surfaced here). Carry it forward so
605
- // phases.govern.operator_consent reflects the consent state. Null when
606
- // --ack was not passed.
432
+ // --ack acknowledges the obligations surfaced here; null when not passed.
607
433
  operator_consent: runOpts.operator_consent || null
608
434
  };
609
435
  }
610
436
 
611
- // --- phase 2: direct ---
612
-
613
437
  function direct(playbookId, directiveId, runOpts = {}) {
614
438
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
615
439
  const d = resolvedPhase(playbook, directiveId, 'direct');
@@ -625,8 +449,6 @@ function direct(playbookId, directiveId, runOpts = {}) {
625
449
  };
626
450
  }
627
451
 
628
- // --- phase 3: look (engine emits, agent executes) ---
629
-
630
452
  function look(playbookId, directiveId, runOpts = {}) {
631
453
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
632
454
  const l = resolvedPhase(playbook, directiveId, 'look');
@@ -636,11 +458,8 @@ function look(playbookId, directiveId, runOpts = {}) {
636
458
  playbook_id: playbookId,
637
459
  directive_id: directiveId,
638
460
  air_gap_mode: airGap,
639
- // Preconditions are surfaced here so the host AI can verify them with its
640
- // own probes (Bash:test -f /proc/version, etc.) and declare the results
641
- // back through submission.precondition_checks. Without this list, the AI
642
- // is blind to the gate and run() will halt with a precondition_unverified
643
- // failure the AI can't diagnose. See AGENTS.md Hard Rule context.
461
+ // The host AI probes these itself and declares the results through
462
+ // submission.precondition_checks; without the list it is blind to the gate.
644
463
  preconditions: (playbook._meta.preconditions || []).map(pc => ({
645
464
  id: pc.id,
646
465
  description: pc.description,
@@ -653,14 +472,10 @@ function look(playbookId, directiveId, runOpts = {}) {
653
472
  },
654
473
  artifacts: (l.artifacts || []).map(a => ({
655
474
  ...a,
656
- // Surface the air-gap alternative as the primary source when air_gap_mode
657
- // is active, so the agent doesn't accidentally hit the network.
658
475
  source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
659
476
  _original_source: a.source,
660
- // In air-gap mode an artifact with NO air_gap_alternative silently keeps
661
- // its original (possibly network-bound) source — defeating air-gap with no
662
- // signal. Flag it so the agent treats the source as not-offline-verified
663
- // and the gap is observable instead of a silent network fallback.
477
+ // An artifact with no alternative keeps its possibly network-bound
478
+ // source, so the flag marks it not-offline-verified.
664
479
  ...(airGap && !a.air_gap_alternative ? { air_gap_alternative_missing: true } : {}),
665
480
  })),
666
481
  collection_scope: l.collection_scope,
@@ -669,16 +484,12 @@ function look(playbookId, directiveId, runOpts = {}) {
669
484
  };
670
485
  }
671
486
 
672
- // --- phase 4: detect ---
673
-
674
487
  /**
675
- * Evaluate artifacts the agent submitted against the playbook's typed
676
- * indicators. Returns a per-indicator hit/miss/inconclusive verdict plus a
488
+ * Phase 4. Evaluates submitted artifacts against the playbook's typed
489
+ * indicators: a per-indicator hit/miss/inconclusive verdict plus a
677
490
  * minimum_signal classification (detected | inconclusive | not_detected).
678
- *
679
- * The agent submits `artifacts` as { artifact_id: { value, captured: true|false, reason? } }
680
- * and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
681
- * record an indicator outcome the agent computed using its own pattern matching.
491
+ * The agent submits `artifacts` as { artifact_id: { value, captured, reason? } }
492
+ * and optionally `signal_overrides` as { indicator_id: verdict }.
682
493
  */
683
494
  function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
684
495
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
@@ -686,13 +497,8 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
686
497
  const artifacts = agentSubmission.artifacts || {};
687
498
  const overrides = agentSubmission.signal_overrides || {};
688
499
 
689
- // v0.11.4 (#71): canonicalize the indicator result vocabulary. Operators
690
- // submit shapes like "no_hit" / "clean" / "ok" / false from years of
691
- // CI/security tooling convention; the engine internally uses
692
- // hit | miss | inconclusive. Without canonicalization every flat-shape
693
- // observation with result:"no_hit" silently fell through to inconclusive
694
- // and broke per-indicator detection. Canonicalization happens here so
695
- // both detect() and normalizeSubmission consumers see the same outcomes.
500
+ // Operators submit the CI/security vocabulary ("no_hit", "clean", "ok"); the
501
+ // engine speaks hit | miss | inconclusive.
696
502
  const canonicalize = (v) => {
697
503
  if (v === true || v === 'hit' || v === 'detected' || v === 'positive') return 'hit';
698
504
  if (v === false || v === 'miss' || v === 'no_hit' || v === 'no-hit' || v === 'clean' || v === 'clear' || v === 'not_hit' || v === 'ok' || v === 'pass' || v === 'negative') return 'miss';
@@ -700,19 +506,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
700
506
  return null; // truly unknown — fall through
701
507
  };
702
508
 
703
- // Per-indicator FP-check attestation map. Operators submit
704
- // signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
705
- // to declare which named false_positive_checks_required[] entries on the
706
- // indicator have been satisfied. An unverified FP check downgrades the
707
- // verdict from 'hit' to 'inconclusive' and surfaces fp_checks_unsatisfied
708
- // on the per-indicator result. See AGENTS.md Hard Rule #6 (compliance
709
- // theater) and AGENTS.md §"detect (AI)" — a `hit` without its FP checks
710
- // is not yet a `detected` classification.
711
- // Optional per-indicator evidence locations the submission supplies
712
- // (`evidence_locations: { "<indicator-id>": ["path", {uri,startLine}] }`).
713
- // Threaded onto each firing indicator so the SARIF renderer can populate
714
- // results[].locations — without this, secret/file findings ship
715
- // location-less and GitHub code-scanning drops them.
509
+ // `evidence_locations: { "<indicator-id>": ["path", {uri,startLine}] }`,
510
+ // threaded onto each firing indicator so the SARIF renderer can fill
511
+ // results[].locations — GitHub code-scanning drops a location-less result.
716
512
  const _evLocs = (agentSubmission && typeof agentSubmission.evidence_locations === 'object'
717
513
  && agentSubmission.evidence_locations !== null && !Array.isArray(agentSubmission.evidence_locations))
718
514
  ? agentSubmission.evidence_locations : {};
@@ -723,35 +519,22 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
723
519
  let fpChecksUnsatisfied = null;
724
520
  if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
725
521
  verdict = override;
726
- // Gate 'hit' verdict on per-indicator false_positive_checks_required
727
- // satisfaction. The FP-check attestation arrives as a sibling key
728
- // '<id>__fp_checks' in signal_overrides; default behavior (no
729
- // attestation) treats every required FP check as UNSATISFIED.
522
+ // A 'hit' is gated on the indicator's false_positive_checks_required,
523
+ // attested through a sibling signal_overrides key '<id>__fp_checks'. No
524
+ // attestation leaves every check UNSATISFIED and downgrades the verdict.
730
525
  if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
731
- // A hostile or buggy attestation may be a Proxy whose property
732
- // accessors throw. The filter below reads `att[fpName]` for each
733
- // required check; an exception inside the read would crash detect()
734
- // and abort the entire run. Wrap the FP-check evaluation in a
735
- // try/catch: on throw, treat ALL required checks as unsatisfied
736
- // (safest default — never silently honor an attestation we couldn't
737
- // read) and surface a runtime_error so the operator sees why.
526
+ // A Proxy attestation whose getters throw must not abort the run: on
527
+ // throw every check is unsatisfied — never honor an unreadable one.
738
528
  try {
739
529
  const attestation = overrides[`${ind.id}__fp_checks`];
740
- // Arrays satisfy `typeof === 'object'` but are NOT a valid
741
- // attestation map. A submission like
742
- // signal_overrides: { sig__fp_checks: [true, true] }
743
- // would otherwise have its truthy entries matched via the index
744
- // fallback (att['0'] === true), silently bypassing every FP-check
745
- // requirement. Reject arrays explicitly so they fall through to
746
- // the empty-attestation branch (every required check
747
- // unsatisfied).
530
+ // An array satisfies `typeof === 'object'` but is not an attestation
531
+ // map: `[true, true]` would match through the index fallback below
532
+ // and bypass every FP-check requirement.
748
533
  const safeAtt = Array.isArray(attestation) ? null : attestation;
749
534
  const att = (safeAtt && typeof safeAtt === 'object') ? safeAtt : {};
750
535
  const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
751
- // Match either by exact name string OR by indexed key '0', '1', ...
752
- // because false_positive_checks_required entries are free-text
753
- // strings, not ids. Operators may attest either by the literal
754
- // string or by index. Default: unsatisfied.
536
+ // The entries are free text, not ids, so an operator may attest by
537
+ // the literal string or by index. Anything else is unsatisfied.
755
538
  if (att[fpName] === true) return false;
756
539
  const idx = ind.false_positive_checks_required.indexOf(fpName);
757
540
  if (idx !== -1 && att[String(idx)] === true) return false;
@@ -762,10 +545,6 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
762
545
  fpChecksUnsatisfied = unsatisfied;
763
546
  }
764
547
  } catch (e) {
765
- // Treat every required check as unsatisfied — we couldn't trust the
766
- // attestation map. Surface the throw so operators can chase the
767
- // root cause (Proxy with a throwing getter, frozen object that
768
- // tripped invariants, etc.).
769
548
  verdict = 'inconclusive';
770
549
  fpChecksUnsatisfied = ind.false_positive_checks_required.slice();
771
550
  if (runOpts && Array.isArray(runOpts._runErrors)) {
@@ -778,12 +557,8 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
778
557
  }
779
558
  }
780
559
  } else {
781
- // An override WAS supplied for this indicator but its value didn't
782
- // canonicalize to hit/miss/inconclusive (e.g. "maybe", "present", a
783
- // number). Pre-fix this was silently dropped — the operator believed
784
- // they'd asserted a result but the signal vanished, yielding a false
785
- // not_detected. Surface it as a runtime_error (mirrors the
786
- // classification_override_invalid signal) so the drop is visible.
560
+ // An override that did not canonicalize ("maybe", a number). Dropping it
561
+ // silently turns an asserted result into a false not_detected.
787
562
  if (rawOverride !== undefined && runOpts && Array.isArray(runOpts._runErrors)) {
788
563
  pushRunError(runOpts._runErrors, {
789
564
  kind: 'signal_override_unrecognized',
@@ -792,12 +567,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
792
567
  message: `signal_overrides["${ind.id}"] value was not recognized (expected hit/miss/inconclusive or a boolean); the signal was ignored.`,
793
568
  }, { dedupeKey: e => e.indicator_id || '' });
794
569
  }
795
- // Without a usable override, treat any captured artifact as evidence
796
- // the indicator could be evaluated. Mark inconclusive if any artifact
797
- // was captured (engine doesn't pattern-match raw artifact content; the
798
- // host AI is responsible for that). With NO captured artifacts, this is
799
- // a clean empty submission — emit 'miss' so the run can reach
800
- // classification:'not_detected' rather than getting stuck inconclusive.
570
+ // The engine does not pattern-match raw artifact content, so a captured
571
+ // artifact means only that the indicator COULD be evaluated. Nothing
572
+ // captured is an empty submission, which 'miss' lets reach not_detected.
801
573
  const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
802
574
  verdict = anyCaptured ? 'inconclusive' : 'miss';
803
575
  }
@@ -811,8 +583,7 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
811
583
  };
812
584
  });
813
585
 
814
- // false-positive profile — engine highlights which FP tests the agent
815
- // should still run against any indicator the agent reported as 'hit'.
586
+ // The FP tests the agent still owes for each indicator it reported as 'hit'.
816
587
  const fpChecksRequired = (det.false_positive_profile || []).filter(fp =>
817
588
  indicatorResults.find(r => r.id === fp.indicator_id && r.verdict === 'hit')
818
589
  );
@@ -821,20 +592,12 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
821
592
  const hasDeterministicHit = hits.some(r => r.deterministic);
822
593
  const hasHighConfHit = hits.some(r => r.confidence === 'high' || r.confidence === 'deterministic');
823
594
 
824
- // Agent override: if signals.detection_classification is explicitly set to
825
- // one of the four legal values, honor it. Engine computes its own
826
- // classification as a fallback. Use the override when the agent has run the
827
- // full false_positive_profile checks and reached an explicit verdict —
828
- // engine-computed classification can't represent "I saw the indicators and
829
- // confirmed they're all benign" without this override.
595
+ // The agent's own verdict after running the full false_positive_profile: the
596
+ // engine classification cannot express "I saw these and they are all benign".
830
597
  const rawOverride = (agentSubmission.signals && agentSubmission.signals.detection_classification);
831
598
  const validOverrides = new Set(['detected', 'inconclusive', 'not_detected', 'clean']);
832
- // Any override that's a non-empty string but NOT in the allowlist (e.g.
833
- // 'present', 'unknown', '', ' detected ', 'Detected') surfaces as a
834
- // runtime_error rather than silently falling through to engine-computed
835
- // classification. Operators submitting case variants / whitespace-padded
836
- // strings deserve a clear diagnostic, not a quiet downgrade. Treat the
837
- // override as absent for classification purposes once recorded.
599
+ // Matching is exact — case-sensitive, unpadded. A near-miss surfaces a
600
+ // runtime_error and is then treated as absent.
838
601
  const overrideIsString = typeof rawOverride === 'string';
839
602
  const overrideIsInAllowlist = overrideIsString && validOverrides.has(rawOverride);
840
603
  if (rawOverride !== undefined && rawOverride !== null && !overrideIsInAllowlist) {
@@ -849,28 +612,19 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
849
612
  }
850
613
  const override = overrideIsInAllowlist ? rawOverride : undefined;
851
614
 
852
- // Refuse ALL classification overrides (`detected`, `clean`,
853
- // `not_detected`) when any indicator was FP-downgraded. A submission
854
- // that maps to `'not_detected'` (either literally or via `'clean'`,
855
- // which maps to `'not_detected'` at this site) MUST NOT hide a
856
- // `verdict: 'hit'` indicator whose `false_positive_checks_required[]`
857
- // were unattested — that's a strictly worse false-negative outcome than
858
- // allowing 'detected' through. Substitute 'inconclusive' and emit a
859
- // runtime_error.
860
- // Record indicator IDs and an unsatisfied-checks count ONLY — never the
861
- // literal FP-check check-name strings (those are an attestation-bypass
862
- // hint for a hostile agent reading the runtime_errors).
615
+ // Every classification override is refused once any indicator was
616
+ // FP-downgraded. The runtime_error records indicator ids and an unsatisfied
617
+ // count only: the literal check names are an attestation-bypass hint.
863
618
  const anyFpDowngrade = indicatorResults.some(r => Array.isArray(r.fp_checks_unsatisfied) && r.fp_checks_unsatisfied.length > 0);
864
619
 
865
620
  let classification;
866
- // Track whether the override was actually honored, so we don't report a
867
- // refused override as "applied" (L6).
621
+ // A refused override must not be reported as applied.
868
622
  let overrideEffective = !!override;
869
623
  if (override) {
870
624
  classification = override === 'clean' ? 'not_detected' : override;
871
625
  if (anyFpDowngrade) {
872
626
  const substituted = 'inconclusive';
873
- const attempted = override; // record what the operator submitted, not the mapped form
627
+ const attempted = override; // what the operator submitted, not the mapped form
874
628
  classification = substituted;
875
629
  overrideEffective = false;
876
630
  if (runOpts && Array.isArray(runOpts._runErrors)) {
@@ -885,12 +639,9 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
885
639
  }, { dedupeKey: e => String(e.attempted) });
886
640
  }
887
641
  } else if (classification === 'not_detected' && hasDeterministicHit) {
888
- // A not_detected/clean override must not silently bury a DETERMINISTIC
889
- // hit. A deterministic indicator firing is high-signal evidence; hiding
890
- // it as not_detected is a strictly worse false-negative than leaving the
891
- // run inconclusive. (Probabilistic hits remain overridable — that's the
892
- // legitimate "I confirmed these are benign" workflow.) Substitute
893
- // inconclusive and surface why.
642
+ // A deterministic hit cannot be buried under not_detected/clean — a
643
+ // worse false negative than an inconclusive run. Probabilistic hits stay
644
+ // overridable; that is the legitimate "these are benign" path.
894
645
  classification = 'inconclusive';
895
646
  overrideEffective = false;
896
647
  if (runOpts && Array.isArray(runOpts._runErrors)) {
@@ -919,57 +670,31 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
919
670
  false_positive_checks_required: fpChecksRequired,
920
671
  classification,
921
672
  minimum_signal_basis: det.minimum_signal?.[classification === 'detected' ? 'detected' : classification === 'not_detected' ? 'not_detected' : 'inconclusive'],
922
- // v0.11.3 #71: surface what detect actually consumed. Operators reading
923
- // the detect output now see whether their flat-shape observations + the
924
- // signal_overrides + the classification override all reached the runner.
673
+ // What detect consumed, so an operator can see what reached the runner.
925
674
  observations_received: Object.keys(agentSubmission.artifacts || {}),
926
675
  signals_received: Object.keys(agentSubmission.signal_overrides || {}),
927
- // v0.11.4 (#73): downstream consumers iterating `indicators_evaluated`
928
- // expect an array, not a count. Restore as array; provide
929
- // `indicators_evaluated_count` for callers wanting the integer.
676
+ // An array — indicators_evaluated_count carries the integer.
930
677
  indicators_evaluated: indicatorResults.map(i => ({
931
678
  signal_id: i.id,
932
679
  outcome: i.verdict,
933
680
  confidence: i.confidence,
934
- // v0.11.5 #85: surface which observation produced this indicator's
935
- // outcome (when the agent submitted it via flat-shape observation +
936
- // indicator + result fields). Null when no observation drove the
937
- // indicator (engine-computed default).
681
+ // The observation behind this outcome; null on the engine default.
938
682
  from_observation: agentSubmission._signal_origins?.[i.id] || null,
939
683
  })),
940
684
  indicators_evaluated_count: indicatorResults.length,
941
- // Only report the override as applied when it was actually honored — a
942
- // refused override (FP-downgrade or deterministic-hit masking) left
943
- // `classification` as inconclusive, so reporting the attempted value here
944
- // would contradict the verdict.
685
+ // Null for a refused override, which would contradict the verdict.
945
686
  classification_override_applied: (override && overrideEffective) ? (override === 'clean' ? 'not_detected' : override) : null,
946
687
  submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
947
- // Pass through any flat-shape observation collisions detected at
948
- // normalize time so analyze() can publish them under
949
- // analyze.signal_origins_with_collisions.
688
+ // Republished by analyze() as analyze.signal_origins_with_collisions.
950
689
  _signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
951
690
  };
952
691
  }
953
692
 
954
- // --- phase 5: analyze ---
955
-
956
- /**
957
- * Mirror the FIRED detect indicators (verdict === 'hit') into a flat
958
- * `{ <indicator-id>: true }` map for the escalation / feeds_into / precondition
959
- * eval contexts. Indicator hits arrive from the collector / AI evidence path as
960
- * `signal_overrides` (which detect() reads); the condition contexts spread
961
- * `agentSubmission.signals`, NOT signal_overrides — so an escalation written as
962
- * `<indicator-id> == true` (the catalog's canonical form) stayed false even when
963
- * that indicator DETECTED, unless the operator redundantly re-submitted the id
964
- * under `signals`. The standard `exceptd collect <playbook>` path never does
965
- * that, so the entire indicator-gated escalation/chain layer was dead on the
966
- * real evidence path. Mirroring fired indicators here binds the condition layer
967
- * to the detect layer. Only verdict 'hit' (FP-checks satisfied) is mirrored — an
968
- * 'inconclusive' indicator is unconfirmed and must not fire an escalation. The
969
- * map is spread with the LOWEST precedence (before agentSignals, which is before
970
- * the engine roots) so an operator can still override a specific id and the
971
- * engine-computed values always win.
972
- */
693
+ // Mirror the fired detect indicators into a flat `{ <indicator-id>: true }` map
694
+ // for the escalation / feeds_into / precondition contexts, which spread
695
+ // `signals` and not `signal_overrides`. Only verdict 'hit' mirrors: an
696
+ // 'inconclusive' indicator is unconfirmed and must not fire an escalation.
697
+ // Spread at the LOWEST precedence, so engine values still win.
973
698
  function firedIndicatorSignals(detectIndicators) {
974
699
  const out = {};
975
700
  for (const ind of (detectIndicators || [])) {
@@ -978,66 +703,31 @@ function firedIndicatorSignals(detectIndicators) {
978
703
  return out;
979
704
  }
980
705
 
981
- /**
982
- * RWEP composition + blast-radius scoring + theater check + framework gap
983
- * mapping + escalation evaluation. Inputs are the detect result + any
984
- * agent-submitted signal_values (e.g. blast_radius classification).
985
- */
706
+ // Phase 5. RWEP composition, blast-radius scoring, theater check, framework gap
707
+ // mapping and escalation evaluation, from the detect result plus agent signals.
986
708
  function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOpts = {}) {
987
709
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
988
710
  const an = resolvedPhase(playbook, directiveId, 'analyze');
989
711
  const directive = findDirective(playbook, directiveId);
990
- // F6/F20/F24: when analyze() is called directly (not via run()), no
991
- // runtime-error accumulator exists in runOpts. Ensure there's always a
992
- // local array so blast_radius / theater / xref errors surface in the
993
- // returned analyze.runtime_errors.
712
+ // A direct analyze() call carries no accumulator, and without a local one
713
+ // blast_radius / theater / xref errors never reach analyze.runtime_errors.
994
714
  if (!Array.isArray(runOpts._runErrors)) {
995
715
  runOpts = { ...runOpts, _runErrors: [] };
996
716
  }
997
717
 
998
- // Resolve catalogued CVEs from the domain.cve_refs list. This list is the
999
- // playbook's CVE scan-coverage enumeration — every CVE this playbook can
1000
- // detect. By itself it is NOT a statement that the operator is affected by
1001
- // any of these CVEs; affected-ness requires evidence correlation in detect.
1002
- //
1003
- // Two distinct sets are computed below:
1004
- //
1005
- // catalogBaselineCves — every CVE the playbook scans for, with full
1006
- // per-CVE catalog context (RWEP / KEV / CVSS / AI-discovery /
1007
- // active-exploitation / patch state). Always populated when the
1008
- // playbook has domain.cve_refs. Each entry carries correlated_via=null
1009
- // and a `note` flagging it as catalog-only.
1010
- //
1011
- // matchedCves — CVEs the operator's submitted evidence actually
1012
- // correlates to. Correlation paths:
1013
- // (a) An indicator fired (verdict === 'hit') whose attack_ref or
1014
- // atlas_ref intersects the CVE's attack_refs / atlas_refs in
1015
- // the catalog.
1016
- // (b) An agentSignal explicitly references the CVE id with a
1017
- // truthy value (`agentSignals[cveId] === true`) or with a
1018
- // string value 'hit' / 'detected' / 'affected'.
1019
- // Each entry carries correlated_via=<reason string> so downstream
1020
- // consumers (CSAF / SARIF / OpenVEX / human renderer) can show the
1021
- // provenance, and so an empty matchedCves means "no evidence
1022
- // correlated to operator's submission" rather than "playbook has
1023
- // no CVEs of interest."
1024
- //
1025
- // VEX filter (agentSignals.vex_filter): a set of CVE IDs the operator has
1026
- // formally declared not_affected via CycloneDX/OpenVEX. VEX-dropped CVEs
1027
- // are removed from BOTH arrays (they're not affected — neither correlated
1028
- // nor part of effective scan coverage for this run).
718
+ // domain.cve_refs enumerates the playbook's scan coverage, never a claim that
719
+ // the operator is affected. catalogBaselineCves is all of it with
720
+ // correlated_via null; matchedCves is the subset the evidence correlates to,
721
+ // and correlated_via names what tied them. agentSignals.vex_filter removes
722
+ // entries from BOTH arrays.
1029
723
  const cveRefs = playbook.domain.cve_refs || [];
1030
724
  const vexFilter = agentSignals.vex_filter instanceof Set ? agentSignals.vex_filter
1031
725
  : (Array.isArray(agentSignals.vex_filter) ? new Set(agentSignals.vex_filter) : null);
1032
- // Distinguish OpenVEX/CycloneDX "drop entirely" dispositions
1033
- // (not_affected / false_positive) from "keep but annotate" dispositions
1034
- // (fixed / resolved). vexFilterFromDoc returns the union; the "fixed" set
1035
- // is computed below from agentSignals.vex_fixed when the operator passes
1036
- // it (CLI populates it from the VEX doc alongside vex_filter).
726
+ // not_affected / false_positive drop entirely; fixed / resolved are kept and
727
+ // annotated. The CLI populates vex_fixed from the VEX doc.
1037
728
  const vexFixed = agentSignals.vex_fixed instanceof Set ? agentSignals.vex_fixed
1038
729
  : (Array.isArray(agentSignals.vex_fixed) ? new Set(agentSignals.vex_fixed) : null);
1039
- // Wrap xref.byCve() so a corrupt catalog (or transient missing-index
1040
- // anomaly) surfaces as a runtime_error rather than crashing analyze().
730
+ // A corrupt catalog or missing index becomes a runtime_error, not a crash.
1041
731
  const _byCveSafe = (id) => {
1042
732
  try { return xref.byCve(id); }
1043
733
  catch (e) {
@@ -1054,24 +744,19 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1054
744
  const vexDropped = vexFilter
1055
745
  ? allCves.filter(c => vexFilter.has(c.cve_id)).map(c => c.cve_id)
1056
746
  : [];
1057
- // VEX-fixed CVEs remain in matched/catalog arrays but get annotated
1058
- // with vex_status:'fixed' downstream so consumers see them as resolved.
1059
- // Source from catalogBaselineCves (post-vexFilter survivors), NOT allCves: a
1060
- // CVE the operator marked BOTH not_affected (dropped by vexFilter) AND fixed
1061
- // is contradictory, and listing it as fixed while it's absent from
1062
- // matched/baseline would have it appear in fixed_cves but nowhere else.
747
+ // From the post-vexFilter survivors, not allCves: a CVE marked BOTH
748
+ // not_affected and fixed would land in fixed_cves and nowhere else.
1063
749
  const vexFixedIds = vexFixed
1064
750
  ? catalogBaselineCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
1065
751
  : [];
1066
752
 
1067
- // Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
753
+ // cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
1068
754
  const correlationsByCve = new Map();
1069
755
  const addCorrelation = (cveId, reason) => {
1070
756
  if (!correlationsByCve.has(cveId)) correlationsByCve.set(cveId, []);
1071
757
  const arr = correlationsByCve.get(cveId);
1072
758
  if (!arr.includes(reason)) arr.push(reason);
1073
759
  };
1074
- // (a) indicator-hit → CVE via shared attack_ref / atlas_ref.
1075
760
  const playbookDetect = resolvedPhase(playbook, directiveId, 'detect');
1076
761
  const indicatorRefs = new Map(); // indicator.id -> { attack_ref, atlas_ref }
1077
762
  for (const ind of (playbookDetect.indicators || [])) {
@@ -1087,7 +772,6 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1087
772
  if (attackHit || atlasHit) addCorrelation(c.cve_id, `indicator_hit:${fired.id}`);
1088
773
  }
1089
774
  }
1090
- // (b) agentSignals explicitly referencing a CVE id.
1091
775
  for (const c of catalogBaselineCves) {
1092
776
  const sig = agentSignals[c.cve_id];
1093
777
  if (sig === true || sig === 'hit' || sig === 'detected' || sig === 'affected') {
@@ -1095,14 +779,9 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1095
779
  }
1096
780
  }
1097
781
 
1098
- // Indicator-level cve_ref correlation. Indicators may declare a
1099
- // cve_ref (string OR string[]) naming CVEs whose presence the indicator
1100
- // pattern-matches. When such an indicator fires AND the named CVE exists
1101
- // in the catalog, the CVE joins matched_cves with correlated_via=
1102
- // 'indicator_cve_ref:<indicator-id>'. The catalog lookup also brings in
1103
- // CVEs the playbook didn't enumerate in domain.cve_refs — they're appended
1104
- // to the working catalog set so the downstream matchedCves filter picks
1105
- // them up. Dedupe is automatic via correlationsByCve (Map keyed on cve_id).
782
+ // An indicator's own cve_ref (string or string[]), correlated as
783
+ // 'indicator_cve_ref:<indicator-id>'. This reaches CVEs domain.cve_refs
784
+ // never enumerated, which join the working set for the filter below.
1106
785
  const extraCatalogCves = [];
1107
786
  const seenCatalogIds = new Set(catalogBaselineCves.map(c => c.cve_id));
1108
787
  for (const fired of firedIndicators) {
@@ -1111,7 +790,6 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1111
790
  const raw = indicator.cve_ref;
1112
791
  const refs = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw ? [raw] : []);
1113
792
  for (const cveId of refs) {
1114
- // VEX-drop these the same as catalog CVEs.
1115
793
  if (vexFilter && vexFilter.has(cveId)) continue;
1116
794
  let cveEntry = catalogBaselineCves.find(c => c.cve_id === cveId);
1117
795
  if (!cveEntry) {
@@ -1129,24 +807,15 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1129
807
 
1130
808
  const matchedCves = workingCatalogCves.filter(c => correlationsByCve.has(c.cve_id));
1131
809
 
1132
- // Per-CVE shape — identical between matched_cves and catalog_baseline_cves
1133
- // so consumers can iterate either without branching. matched_cves entries
1134
- // carry a non-null correlated_via array; catalog_baseline_cves entries
1135
- // carry correlated_via:null and a `note` clarifying the field's intent.
810
+ // One shape for both arrays, so consumers iterate either without branching.
1136
811
  const cveShape = (c, correlatedVia) => {
1137
- // Annotate VEX-fixed CVEs with vex_status. matched_cves still
1138
- // includes them so audit trails and SBOM reports surface "we know this
1139
- // is in scope but vendor declared it fixed."
812
+ // VEX-fixed CVEs stay in matched_cves so the audit trail keeps them.
1140
813
  const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
1141
814
  return {
1142
815
  cve_id: c.cve_id,
1143
- // attack_class is the coarse chainable taxonomy (kernel-lpe /
1144
- // mcp-supply-chain / ai-c2 / prompt-injection / container-escape / …)
1145
- // the sbom -> deep-dive feeds_into rules quantify over
1146
- // (`any matched_cve.attack_class == 'kernel-lpe'`). It comes from the
1147
- // catalog entry's explicit attack_class only — no fuzzy inference from
1148
- // the free-form `type`, so an unclassified CVE stays null and the chain
1149
- // correctly does not fire (no false escalation) rather than misrouting.
816
+ // What the sbom → deep-dive feeds_into rules quantify over. The catalog
817
+ // entry's explicit attack_class only — never inferred from the free-form
818
+ // `type`, so an unclassified CVE stays null and the chain does not fire.
1150
819
  attack_class: c.entry?.attack_class ?? null,
1151
820
  rwep: c.rwep_score,
1152
821
  cvss_score: c.entry?.cvss_score ?? null,
@@ -1177,33 +846,16 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1177
846
  note: 'Catalog-baseline entry — this CVE is in the playbook\'s scan coverage but no submitted evidence correlated to it. Not a statement that the operator is affected.',
1178
847
  }));
1179
848
 
1180
- // RWEP composition: start from the per-CVE rwep_score of evidence-correlated
1181
- // matches (NOT catalog baseline) so RWEP base reflects what the operator's
1182
- // evidence actually surfaced. The "max" reduction across matched CVEs is
1183
- // intentional — RWEP is a "worst-case real-world exploit priority", not
1184
- // an arithmetic average. The most-exploitable CVE in the set drives the
1185
- // base; secondary CVEs add via rwep_inputs adjustments below rather than
1186
- // through base summing (which would double-count overlapping risk).
1187
- // vex_status='fixed' CVEs do NOT drive the base — vendor declared them
1188
- // resolved. They still appear in matched_cves for audit traceability but
1189
- // don't elevate RWEP.
849
+ // Evidence-correlated matches only, reduced with max: RWEP is a worst-case
850
+ // priority, so the most-exploitable CVE sets the base and summing bases would
851
+ // double-count overlapping risk. A vex_status='fixed' CVE never drives it.
1190
852
  const rwepEligible = matchedCves.filter(c => !(vexFixed && vexFixed.has(c.cve_id)));
1191
853
  const baseRwep = rwepEligible.length ? Math.max(...rwepEligible.map(c => c.rwep_score)) : 0;
1192
854
 
1193
- // rwep_factor semantics: each rwep_input.weight is conditional on the
1194
- // matched CVE having a corresponding attribute. Multiply weight by a
1195
- // factor in [0, 1] derived from the first matched CVE's catalog
1196
- // attribute so a weight only fires when its CVE-attribute supports it
1197
- // (e.g. active_exploitation +25 only when the matched CVE is under
1198
- // active exploitation). blast_radius is sourced from the analyze-phase
1199
- // blast_radius_score / 5 (rubric ceiling). Negative weights
1200
- // (patch_available, live_patch_available) keep their sign so a patched
1201
- // CVE deducts the full magnitude when the catalog confirms a
1202
- // patch is available.
1203
- //
1204
- // Aliasing: playbooks ship rwep_factor values `public_poc` and
1205
- // `ai_weaponization` for what F5 calls `poc_available` and `ai_factor`.
1206
- // Both spellings resolve here.
855
+ // Each rwep_input.weight scales by a [0, 1] factor from the matched CVE's
856
+ // catalog attributes. Negative weights keep their sign, so a patched CVE
857
+ // deducts in full. `public_poc` and `ai_weaponization` are catalog aliases for
858
+ // `poc_available` and `ai_factor`; both spellings resolve.
1207
859
  const _factorScale = (factorName, cve, blastScore) => {
1208
860
  if (!cve) return 0;
1209
861
  switch (factorName) {
@@ -1211,15 +863,9 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1211
863
  return cve.cisa_kev === true ? 1 : 0;
1212
864
  case 'active_exploitation': {
1213
865
  const v = cve.active_exploitation || (cve.entry && cve.entry.active_exploitation);
1214
- // Route through the shared scoring resolver instead of an inline
1215
- // `ladder[v] ?? 0` lookup so a stray-cased value ('Confirmed') scales
1216
- // identically to the catalog scorer AND an out-of-vocabulary value
1217
- // ('in-the-wild') surfaces the RWEP_AE_UNRECOGNISED warning rather than
1218
- // silently zeroing the active-exploitation weight. activeExploitationMultiplier
1219
- // (not the bare resolveActiveExploitation().multiplier) is used precisely so
1220
- // the no-match path is OBSERVABLE — the "out-of-vocab token must surface,
1221
- // not silently default" class. For every canonical catalog value the
1222
- // returned multiplier is identical to the prior inline lookup.
866
+ // The shared resolver, not an inline `ladder[v] ?? 0`: a stray-cased
867
+ // value scales as the catalog scorer scales it, and an out-of-vocabulary
868
+ // one raises RWEP_AE_UNRECOGNISED instead of silently zeroing.
1223
869
  return scoring.activeExploitationMultiplier(v);
1224
870
  }
1225
871
  case 'poc_available':
@@ -1242,24 +888,19 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1242
888
  case 'reboot_required':
1243
889
  return cve.entry?.patch_required_reboot === true ? 1 : 0;
1244
890
  case 'blast_radius': {
1245
- // blast_radius weights scale by the 0-5 rubric score so a max-blast
1246
- // finding gets full weight and a low-blast finding gets a fraction.
1247
891
  if (typeof blastScore !== 'number' || blastScore < 0) return 0;
1248
892
  return Math.min(1, blastScore / 5);
1249
893
  }
1250
894
  default:
1251
- // Unknown factor: fire as binary (legacy behavior) so playbooks with
1252
- // novel rwep_factor strings don't silently zero out.
895
+ // An unrecognised factor fires binary, so a playbook shipping a novel
896
+ // rwep_factor string does not silently zero out.
1253
897
  return 1;
1254
898
  }
1255
899
  };
1256
900
 
1257
- // blast_radius_score validation. No supplied value → null +
1258
- // signal='default'. Supplied value out of [0,5] → null +
1259
- // signal='rejected' + runtime_error. Supplied value in range → use it +
1260
- // signal='supplied'. The runner never defaults to a rubric entry — that
1261
- // would be the opposite of safe-default when the rubric's lowest entry
1262
- // is the LOWEST-blast row.
901
+ // blast_radius_score: in-range is 'supplied', absent is null + 'default',
902
+ // out of [0,5] is null + 'rejected' + runtime_error. Never a rubric entry —
903
+ // the rubric's lowest row is the LOWEST-blast one, so a guess understates.
1263
904
  const blastRubric = an.blast_radius_model?.scoring_rubric || [];
1264
905
  let blastRadiusScore = null;
1265
906
  let blastRadiusSignal = 'default';
@@ -1276,56 +917,26 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1276
917
  }
1277
918
  }
1278
919
  }
1279
- // Use the first evidence-correlated CVE as the canonical attribute
1280
- // source for factor scaling. If matchedCves is empty there's no per-CVE
1281
- // evidence to gate on. v0.12.15: the prior fallback was
1282
- // `factorCve = null` → every factor returned 0 → catalog-shape playbooks
1283
- // (secrets, library-author, crypto-codebase, framework, cred-stores,
1284
- // containers, runtime, crypto, ai-api) that detect WITHOUT a per-CVE
1285
- // evidence correlation emitted `weight_applied: 0` for every fired
1286
- // indicator, producing `adjusted: 0` for every detection. The e2e suite
1287
- // caught this — 9/20 scenarios failed `json_path_min.adjusted >= N`.
1288
- //
1289
- // Domain-level fallback: when no evidence-correlated CVE is available,
1290
- // use the highest-rwep_score entry from `workingCatalogCves` (which is
1291
- // built from `playbook.domain.cve_refs[]` — the playbook's canonical
1292
- // "what we're about"). This preserves factor-scaling semantics while
1293
- // recognizing that a catalog-shape playbook's threat class is already
1294
- // declared by its domain refs. The factor-scale annotation surfaces
1295
- // `factor_cve_source: 'evidence' | 'domain' | 'none'` so operators see
1296
- // which fallback was used.
920
+ // Factor scaling reads one CVE, resolved in order and annotated as
921
+ // factor_cve_source: 'evidence' (an RWEP-eligible match), 'domain' (the
922
+ // highest-rwep entry from domain.cve_refs), or 'none'.
1297
923
  let factorCveSource = 'none';
1298
- // Prefer an RWEP-eligible (non-VEX-fixed) matched CVE to drive factor
1299
- // scaling — a vendor-patched CVE must not inflate adjusted RWEP via its
1300
- // exploitation / KEV / PoC multipliers. Do NOT fall back to matchedCves[0]:
1301
- // when EVERY evidence-correlated CVE is VEX-fixed (rwepEligible empty but
1302
- // matchedCves non-empty) the finding is remediated, so factor scaling must be
1303
- // suppressed entirely — base is already 0 and the fired factors must not
1304
- // raise the adjusted score (a vendor-fixed CVE's KEV/exploitation/PoC would
1305
- // otherwise lift it above 0). The domain-CVE and class-weight fallbacks below
1306
- // are skipped in that case too, so every fired factor scales by 0 via
1307
- // _factorScale(factor, null, …).
924
+ // Never fall back to matchedCves[0]: when every correlated CVE is VEX-fixed
925
+ // the finding is remediated, and a patched CVE's KEV / exploitation / PoC
926
+ // multipliers would lift the adjusted score above its base of 0.
1308
927
  const allMatchedVexFixed = matchedCves.length > 0 && rwepEligible.length === 0;
1309
928
  let factorCve = rwepEligible[0] || null;
1310
929
  if (factorCve) {
1311
930
  factorCveSource = 'evidence';
1312
931
  } else if (!allMatchedVexFixed && workingCatalogCves.length > 0) {
1313
- // Highest rwep_score from domain refs.
1314
932
  factorCve = workingCatalogCves.reduce((worst, c) =>
1315
933
  (typeof c.rwep_score === 'number' && (!worst || c.rwep_score > worst.rwep_score)) ? c : worst,
1316
934
  null);
1317
935
  if (factorCve) factorCveSource = 'domain';
1318
936
  }
1319
- // v0.12.15: five shipped playbooks (secrets, library-author,
1320
- // crypto-codebase, framework, cred-stores, containers, runtime, crypto,
1321
- // ai-api) ship with empty `domain.cve_refs` because their attack class is
1322
- // class-of-vulnerability rather than CVE-specific. For those playbooks
1323
- // neither evidence-correlation NOR the domain-CVE fallback yields a
1324
- // factorCve, so every fired indicator's `weight_applied` was forced to
1325
- // zero by `_factorScale` returning 0. Fall back to the pre-v0.12.14
1326
- // semantics for this case only: apply the declared weight as-is
1327
- // (factor_scale=1, legacy semantics). The factor_cve_source annotation
1328
- // surfaces 'class' so operators see which mode the run used.
937
+ // A class-of-vulnerability playbook ships an empty domain.cve_refs, so
938
+ // neither rung above yields a factorCve and every indicator would scale to
939
+ // zero. Those apply the declared weight as-is, annotated 'class'.
1329
940
  const _classScaleFallback = !factorCve && !allMatchedVexFixed;
1330
941
  let adjustedRwep = baseRwep;
1331
942
  const rwepBreakdown = [];
@@ -1336,17 +947,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1336
947
  rwepBreakdown.push({ signal_id: input.signal_id, rwep_factor: input.rwep_factor, weight_applied: 0, fired: false, factor_scale: 0 });
1337
948
  continue;
1338
949
  }
1339
- // v0.12.15: class-of-vulnerability playbooks (no factorCve from
1340
- // evidence OR domain) apply weights as-is via the legacy semantics.
1341
- // For CVE-anchored playbooks, scale by the matched CVE's attributes.
1342
- // Class fallback covers blast_radius too — when the agent submitted a
1343
- // blast score, _factorScale honors it; otherwise the class-fallback
1344
- // applies full weight (matching pre-v0.12.14 behavior, where every
1345
- // fired indicator contributed its full declared weight).
950
+ // An operator-supplied blast score is honored in either mode.
1346
951
  let scale, factorCveSourceForBreakdown;
1347
952
  if (_classScaleFallback) {
1348
953
  if (input.rwep_factor === 'blast_radius' && typeof blastRadiusScore === 'number') {
1349
- // Operator-supplied blast score is still honored even in class mode.
1350
954
  scale = Math.min(1, blastRadiusScore / 5);
1351
955
  } else {
1352
956
  scale = 1;
@@ -1370,20 +974,9 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1370
974
  }
1371
975
  adjustedRwep = Math.max(0, Math.min(100, adjustedRwep));
1372
976
 
1373
- // compliance_theater_check — engine surfaces the test; agent runs it; we
1374
- // accept the verdict in agentSignals.theater_verdict. When agent didn't
1375
- // submit a verdict but the detect phase reached a clear classification,
1376
- // derive one rather than leaving the field stuck in 'pending_agent_run':
1377
- // detect.classification = not_detected → theater_verdict = clear
1378
- // detect.classification = detected → theater_verdict = pending_agent_run
1379
- // (agent still must run reality_test)
1380
- // detect.classification = inconclusive → theater_verdict = pending_agent_run
1381
- // Aliases 'clean' / 'no_theater' map to 'clear' for ergonomics.
1382
- //
1383
- // Validate agentSignals.theater_verdict against an allowlist so
1384
- // downstream consumers (CSAF/SARIF/OpenVEX) never emit bundles with
1385
- // garbage verdicts like "TODO" or free-text strings. Allowlist: clear,
1386
- // present, theater, pending_agent_run, unknown.
977
+ // The engine surfaces the theater test, the agent runs it, and the verdict
978
+ // arrives as agentSignals.theater_verdict. The allowlist keeps free text out
979
+ // of the CSAF / SARIF / OpenVEX bundles.
1387
980
  const _theaterAllowlist = new Set(['clear', 'present', 'theater', 'pending_agent_run', 'unknown']);
1388
981
  let theaterVerdict = agentSignals.theater_verdict;
1389
982
  if (theaterVerdict === 'clean' || theaterVerdict === 'no_theater') theaterVerdict = 'clear';
@@ -1403,50 +996,29 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1403
996
  }
1404
997
  theaterVerdict = theaterVerdict || (an.compliance_theater_check ? 'pending_agent_run' : null);
1405
998
 
1406
- // framework_gap_mapping — engine emits the mapping verbatim; analyze does
1407
- // not compute new gaps here, just attaches the playbook-declared ones.
999
+ // The playbook-declared gaps, emitted verbatim; analyze computes none.
1408
1000
  const frameworkGaps = an.framework_gap_mapping || [];
1409
1001
 
1410
- // escalation criteria are evaluated AFTER the analyze result is assembled
1411
- // (below the result literal) so conditions can reference `analyze.*` and
1412
- // `finding.*` paths — the same roots close()'s feeds_into context
1413
- // resolves. The flat keys (rwep, blast_radius_score, theater_verdict,
1414
- // agent signals) remain available unchanged.
1002
+ // Escalation criteria evaluate below the result literal so their conditions
1003
+ // can reference `analyze.*` and `finding.*`.
1415
1004
  const escalations = [];
1416
- const runtimeErrors = []; // E3: collect regex-eval errors during analyze
1005
+ const runtimeErrors = [];
1417
1006
  const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
1418
1007
 
1419
1008
  const result = {
1420
1009
  phase: 'analyze',
1421
1010
  playbook_id: playbookId,
1422
1011
  directive_id: directiveId,
1423
- // Hard Rule #1 (AGENTS.md): every CVE reference must carry CVSS + KEV +
1424
- // PoC + AI-discovery + active-exploitation + patch/live-patch availability.
1425
- // Pull every required field from the catalog entry; null is only emitted
1426
- // when the catalog itself lacks the value, never when we just forgot to
1427
- // forward it. EPSS is included because validate-cves --live populates it.
1428
- //
1429
- // matched_cves — evidence-correlated only. Each entry has a non-null
1430
- // correlated_via[] array naming the indicator hits or agent signals that
1431
- // tied the operator's submission to this CVE. Empty array means the
1432
- // playbook's scan coverage saw no matching evidence in this run.
1012
+ // Every CVE entry carries CVSS, KEV, PoC, AI-discovery, active-exploitation
1013
+ // and patch state (AGENTS.md Hard Rule #1) from the catalog: a null means
1014
+ // the catalog lacks the value, never that the runner dropped it.
1433
1015
  matched_cves: matchedCveEntries,
1434
- // catalog_baseline_cves — every CVE the playbook scans for, with the
1435
- // same per-CVE shape but correlated_via=null and a note explaining the
1436
- // field is scan-coverage metadata, NOT an operator-affected list. Use
1437
- // this when surfacing "what CVEs does this playbook check for?" Use
1438
- // matched_cves when surfacing "what CVEs is the operator actually
1439
- // affected by based on submitted evidence?"
1016
+ // Scan coverage, not an affected list; correlated_via is null throughout.
1440
1017
  catalog_baseline_cves: catalogBaselineEntries,
1441
- // rwep base is reduced via Math.max across matched CVEs. Surface the
1442
- // reduction strategy as a discoverable field so operators reading the
1443
- // bundle understand the semantics without grepping source.
1444
1018
  rwep: { base: baseRwep, adjusted: adjustedRwep, breakdown: rwepBreakdown, threshold: directive ? resolvedPhase(playbook, directiveId, 'direct').rwep_threshold : null, _rwep_base_strategy: 'max' },
1445
1019
  blast_radius_score: blastRadiusScore,
1446
- // Visible annotation of where blast_radius_score came from:
1447
- // 'supplied' — operator/agent provided a value in [0, 5].
1448
- // 'default' — no value supplied; runner returned null (no rubric guess).
1449
- // 'rejected' — value supplied but out of range; treated as default + runtime_error.
1020
+ // 'supplied' | 'default' (none given, no rubric guess) | 'rejected'
1021
+ // (out of range; treated as default and surfaced as a runtime_error).
1450
1022
  blast_radius_signal: blastRadiusSignal,
1451
1023
  blast_radius_basis: blastRubric.find(r => r.blast_radius_score === blastRadiusScore) || null,
1452
1024
  compliance_theater_check: {
@@ -1454,58 +1026,38 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1454
1026
  audit_evidence: an.compliance_theater_check?.audit_evidence,
1455
1027
  reality_test: an.compliance_theater_check?.reality_test,
1456
1028
  verdict: theaterVerdict,
1457
- // Render verdict_text for both 'theater' AND 'present' verdicts
1458
- // ('present' is a synonym used by some playbooks for "theater is here").
1029
+ // 'present' is a playbook synonym for 'theater'; both render the text.
1459
1030
  verdict_text: (theaterVerdict === 'theater' || theaterVerdict === 'present')
1460
1031
  ? an.compliance_theater_check?.theater_verdict_if_gap
1461
1032
  : null
1462
1033
  },
1463
1034
  framework_gap_mapping: frameworkGaps,
1464
1035
  escalations,
1465
- // v0.11.5 (#82): expose detect's per-indicator results + classification
1466
- // here so close()'s bundle builders can iterate indicators that fired
1467
- // and emit them as SARIF results / OpenVEX statements / CSAF notes.
1468
- // Prefixed with underscore to signal "for internal/render use".
1036
+ // Underscore marks these render-internal: close()'s bundle builders read
1037
+ // them to emit SARIF results / OpenVEX statements / CSAF notes.
1469
1038
  _detect_indicators: detectResult.indicators || [],
1470
1039
  _detect_classification: detectResult.classification,
1471
- // Non-underscore alias so catalog feeds_into / escalation conditions that
1472
- // reference the natural `analyze.classification` path resolve (the
1473
- // underscore-prefixed key was render-internal only, so those conditions
1474
- // resolved undefined and were silently dead — e.g. citation-hygiene →
1475
- // sbom, crypto-codebase → secrets). The underscore key stays for the
1476
- // SARIF/CSAF render consumers. On the detect-skip path run() re-sets
1477
- // analyze.classification to 'skipped', which is the same value
1478
- // detectResult.classification already carries there, so this is benign.
1040
+ // The path catalog feeds_into / escalation conditions actually write —
1041
+ // without the alias they resolve undefined and are silently dead.
1479
1042
  classification: detectResult.classification,
1480
1043
  vex: vexFilter ? {
1481
1044
  filter_applied: true,
1482
1045
  dropped_cve_count: vexDropped.length,
1483
1046
  dropped_cves: vexDropped,
1484
- // Vendor-fixed CVEs are a KEEP disposition — they stay in matched_cves
1485
- // annotated vex_status:'fixed' and never enter vexDropped. Surface them
1486
- // so the two dispositions are distinguishable and the note can be
1487
- // accurate (the drop note must not list a keep-disposition as a reason).
1047
+ // A keep disposition — these stay in matched_cves, never in vexDropped.
1488
1048
  fixed_cves: vexFixedIds,
1489
1049
  fixed_cve_count: vexFixedIds.length,
1490
1050
  note: vexDropped.length
1491
1051
  ? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected / false_positive. Vendor-fixed CVEs are NOT dropped — they remain in matched_cves with vex_status:'fixed'. The dropped CVEs remain in cve-catalog.json; the disposition lives in the VEX file.`
1492
1052
  : "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected / false_positive set)."
1493
1053
  } : null,
1494
- // Regex-eval failures surfaced here so operators can see WHICH
1495
- // condition expression crashed without the runner dying. Only present
1496
- // when at least one evalCondition() call hit a regex exception during
1497
- // this analyze pass; runOpts._runErrors is the same accumulator
1498
- // populated by run() across all phases, so callers reading this field
1499
- // see every regex problem in the run.
1054
+ // The run-wide accumulator: every non-fatal anomaly, without the run dying.
1500
1055
  runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
1501
- // Collisions when two flat-shape observations targeted the same
1502
- // indicator id. Empty when there were no collisions or no flat-shape
1503
- // observations submitted.
1056
+ // Two flat-shape observations that targeted the same indicator id.
1504
1057
  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() : [])
1505
1058
  };
1506
1059
 
1507
- // analyzeFindingShape is a pure transform but defensive-wrap it (same as
1508
- // close()) so a malformed analyze result can't abort escalation handling.
1060
+ // Wrapped so a malformed analyze result cannot abort escalation handling.
1509
1061
  let findingShape;
1510
1062
  try { findingShape = analyzeFindingShape(result); }
1511
1063
  catch (e) {
@@ -1516,47 +1068,24 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1516
1068
  }
1517
1069
  for (const ec of an.escalation_criteria || []) {
1518
1070
  if (evalCondition(ec.condition, { ...firedIndicatorSignals(detectResult.indicators), ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, compliance_theater_check: result.compliance_theater_check, jurisdiction_obligations: (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [], analyze: result, matched_cve: result.matched_cves || [],
1519
- // finding.* is two-sourced: the engine computes the CVE/severity-derived
1520
- // keys (severity, rwep_adjusted, matched_cve_*, blast_radius_score,
1521
- // active_exploitation, framework/control_id_first) via analyzeFindingShape,
1522
- // while the DESCRIPTIVE keys the catalog conditions gate on
1523
- // (finding.includes_*, finding.cve_class, finding.tool_surface, …) are
1524
- // host-AI-asserted — the agent knows whether the finding includes a
1525
- // cloud-role-assumption path. Merge agent-supplied finding sub-fields
1526
- // UNDER the engine shape so the descriptive keys survive while engine-owned
1527
- // keys still WIN on collision (a poisoning signals.finding.severity can't
1528
- // override the engine-computed severity). The !Array.isArray guard rejects
1529
- // an array (typeof [] === 'object') that would inject numeric-index noise;
1530
- // null is already excluded by the leading &&.
1071
+ // finding.* is two-sourced: analyzeFindingShape computes the
1072
+ // CVE/severity keys, while the descriptive ones the catalog gates on
1073
+ // (finding.includes_*, cve_class, tool_surface) are host-AI-asserted.
1074
+ // Agent fields merge UNDER the engine shape; !Array.isArray rejects an
1075
+ // array's numeric-index noise.
1531
1076
  finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...findingShape } }, playbook)) {
1532
1077
  escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
1533
1078
  }
1534
1079
  }
1535
- // Escalation evaluation may have appended regex-eval diagnostics; refresh
1536
- // the snapshot so analyze.runtime_errors reflects them.
1080
+ // Escalation evaluation may have appended diagnostics; refresh the snapshot.
1537
1081
  result.runtime_errors = (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []);
1538
1082
  return result;
1539
1083
  }
1540
1084
 
1541
- /**
1542
- * Extract VEX disposition sets from a CycloneDX/OpenVEX document.
1543
- *
1544
- * OpenVEX `fixed` and `not_affected` must NOT collapse into a single
1545
- * "drop" set — they have different semantics:
1546
- *
1547
- * - not_affected / false_positive → drop from matched_cves entirely.
1548
- * The vendor has formally declared the product not vulnerable; the CVE
1549
- * is not in scope.
1550
- * - fixed / resolved → KEEP in matched_cves but annotate vex_status:'fixed'.
1551
- * The product was vulnerable; the vendor shipped a patch. Operators
1552
- * still need audit trails, SBOM coverage, and confirmation that the
1553
- * fix landed in their build.
1554
- *
1555
- * Returns a `Set<string>` for the legacy "drop" set (the function's
1556
- * historical contract), with `.fixed` attached as an own property for
1557
- * callers that want the split. The CLI passes both as
1558
- * agentSignals.vex_filter + agentSignals.vex_fixed to analyze().
1559
- */
1085
+ // The VEX disposition sets of a CycloneDX/OpenVEX document, which must not
1086
+ // collapse into one "drop" set: not_affected / false_positive drop from
1087
+ // matched_cves, while fixed / resolved are KEPT and annotated. Returns the drop
1088
+ // set as a Set with the fixed set attached as its own `.fixed` property.
1560
1089
  function vexFilterFromDoc(doc) {
1561
1090
  const out = new Set();
1562
1091
  const fixed = new Set();
@@ -1565,9 +1094,6 @@ function vexFilterFromDoc(doc) {
1565
1094
  return out;
1566
1095
  }
1567
1096
 
1568
- // CycloneDX shape — analysis.state values per CycloneDX VEX spec:
1569
- // not_affected / false_positive → drop
1570
- // resolved → fixed-annotation
1571
1097
  for (const v of (doc.vulnerabilities || [])) {
1572
1098
  const state = v.analysis && v.analysis.state;
1573
1099
  if (state === 'not_affected' || state === 'false_positive') {
@@ -1576,7 +1102,6 @@ function vexFilterFromDoc(doc) {
1576
1102
  if (v.id) fixed.add(v.id);
1577
1103
  }
1578
1104
  }
1579
- // OpenVEX shape
1580
1105
  for (const s of (doc.statements || [])) {
1581
1106
  const id = s.vulnerability && (s.vulnerability['@id'] || s.vulnerability.name || s.vulnerability);
1582
1107
  if (typeof id !== 'string') continue;
@@ -1587,11 +1112,8 @@ function vexFilterFromDoc(doc) {
1587
1112
  return out;
1588
1113
  }
1589
1114
 
1590
- // Cap a summary line at `max` chars on a word boundary, appending an ellipsis
1591
- // so a truncated line is visibly marked rather than cut mid-token. A raw
1592
- // .slice() left blocked-preflight summaries ending mid-word ("...and conne")
1593
- // with no signal that anything was dropped (the full text remains in the JSON
1594
- // envelope's reason/remediation fields).
1115
+ // Cap on a word boundary and mark the cut, so a truncated line reads as
1116
+ // truncated. The full text stays in the envelope's reason / remediation.
1595
1117
  function capSummary(s, max = 240) {
1596
1118
  if (typeof s !== 'string' || s.length <= max) return s;
1597
1119
  const slice = s.slice(0, max - 1);
@@ -1600,30 +1122,18 @@ function capSummary(s, max = 240) {
1600
1122
  return base.replace(/[\s—.,;:-]+$/, '') + '…';
1601
1123
  }
1602
1124
 
1603
- // --- phase 6: validate ---
1604
-
1605
1125
  function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
1606
1126
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
1607
- // Remediation-path preconditions are evaluated through the SAME evalCondition
1608
- // the analyze/close phases use, so the precondition context must expose the
1609
- // SAME engine-computed roots those phases thread in — otherwise a precondition
1610
- // that gates on `rwep`, `finding.severity`, `matched_cve.*`,
1611
- // `blast_radius_score`, or `theater_verdict` resolves null and can NEVER be
1612
- // satisfied, so its remediation path is only ever reachable as the priority
1613
- // fallback (kernel's `any matched_cve.vector matches 'userns|bpf|ptrace|kptr'`
1614
- // hardening-compensation path was permanently unsatisfiable for this reason).
1615
- // agentSignals spread FIRST so the engine-computed values WIN on collision (a
1616
- // poisoning signals.rwep can't override the engine value) — mirrors the
1617
- // escalation (analyze) and feeds_into (close) contexts. finding.* merges the
1618
- // host-AI-asserted descriptive keys UNDER the engine shape with the same
1619
- // !Array.isArray guard used there.
1127
+ // Remediation-path preconditions run through the same evalCondition as
1128
+ // analyze and close, so this context must expose the same engine-computed
1129
+ // roots: one gating on `rwep`, `finding.severity` or `matched_cve.*`
1130
+ // otherwise resolves null and can never be satisfied. agentSignals spread
1131
+ // FIRST so engine values win on collision.
1620
1132
  const govern = (playbook.phases && playbook.phases.govern) || {};
1621
1133
  let findingShape = {};
1622
1134
  try { findingShape = analyzeFindingShape(analyzeResult || {}); } catch { findingShape = {}; }
1623
1135
  const evalCtx = {
1624
- // Fired detect indicators mirrored in (lowest precedence) so a precondition
1625
- // referencing an indicator id resolves on the collector path. See
1626
- // firedIndicatorSignals.
1136
+ // Lowest precedence, so an indicator id resolves on the collector path.
1627
1137
  ...firedIndicatorSignals(analyzeResult && analyzeResult._detect_indicators),
1628
1138
  ...agentSignals,
1629
1139
  rwep: analyzeResult && analyzeResult.rwep ? analyzeResult.rwep.adjusted : undefined,
@@ -1638,12 +1148,10 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1638
1148
  };
1639
1149
  const v = resolvedPhase(playbook, directiveId, 'validate');
1640
1150
 
1641
- // Pick the highest-priority remediation_path whose preconditions are all
1642
- // either satisfied by agentSignals or marked unverified=allow.
1151
+ // Lower priority number sorts first, per the schema convention.
1643
1152
  const paths = (v.remediation_paths || []).slice().sort((a, b) => a.priority - b.priority);
1644
- // Indicators that actually fired this run — used to prefer a remediation
1645
- // that addresses the finding (via its for_signals linkage) over the bare
1646
- // priority-1 default when no path's preconditions are verified.
1153
+ // The indicators that fired, so a remediation linked to one through
1154
+ // for_signals can outrank the bare priority-1 default.
1647
1155
  const firedSignalIds = new Set(
1648
1156
  (analyzeResult._detect_indicators || []).filter(i => i.verdict === 'hit').map(i => i.id)
1649
1157
  );
@@ -1661,15 +1169,9 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1661
1169
  if (allSatisfied) satisfiedIds.add(p.id);
1662
1170
  considered.push({ id: p.id, priority: p.priority, all_satisfied: allSatisfied, addresses_fired_signal: addressesFired(p), preconditions: pcResult });
1663
1171
  }
1664
- // Precedence (paths is priority-sorted, so `find` returns the highest-priority
1665
- // match). Relevance to a fired indicator outranks a satisfied-but-unrelated
1666
- // path: recommending a ready-to-apply remediation that does NOT address the
1667
- // finding (just because its preconditions happen to hold) was the original
1668
- // defect — for_signals must beat it.
1669
- // 1. addresses a fired indicator AND preconditions satisfied (relevant + ready)
1670
- // 2. addresses a fired indicator (relevant; operator must meet its preconditions)
1671
- // 3. preconditions satisfied (no fired-signal-linked path — actionable fallback)
1672
- // 4. priority-1 (nothing else — at least propose the top path)
1172
+ // paths is priority-sorted, so each `find` takes the highest-priority match.
1173
+ // Relevance to a fired indicator outranks a satisfied-but-unrelated path. The
1174
+ // last rung is priority-1, so something is always proposed.
1673
1175
  const selected =
1674
1176
  paths.find(p => addressesFired(p) && satisfiedIds.has(p.id))
1675
1177
  || paths.find(addressesFired)
@@ -1677,30 +1179,11 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1677
1179
  || paths[0]
1678
1180
  || null;
1679
1181
 
1680
- // selected_remediation selection logic:
1681
- // 1. Iterate remediation_paths sorted by priority ASC (lower number =
1682
- // higher priority per schema convention).
1683
- // 2. Pick the FIRST path whose every precondition (evaluated against
1684
- // agentSignals + playbook context) is satisfied.
1685
- // 3. Fallback: when nothing satisfies, surface the highest-priority
1686
- // path anyway so the agent has SOMETHING to propose to the operator —
1687
- // better than emitting null and forcing the agent to guess.
1688
- // Above this block: paths.sort + the loop populating `considered` +
1689
- // `selected`. `remediation_options_considered[]` carries the full per-path
1690
- // precondition trace so operators can see why a higher-priority path was
1691
- // skipped.
1692
-
1693
- // Regression schedule. Returns a structured object with next_run +
1694
- // event_triggers + unparseable. Backwards compatibility: keep
1695
- // regression_next_run as the ISO string (or null) so existing CSAF /
1696
- // attestation consumers don't break; expose the structured form
1697
- // separately.
1182
+ // regression_next_run stays the plain ISO string CSAF and attestation
1183
+ // consumers read; the structured form sits alongside it.
1698
1184
  const triggers = v.regression_trigger || [];
1699
1185
  const regressionResult = computeRegressionNextRun(triggers);
1700
1186
 
1701
- // Reason annotation for null next_run — operators see WHY a schedule
1702
- // didn't emit a calendar date (no day intervals declared, every trigger
1703
- // is event-driven, or every trigger was unparseable).
1704
1187
  let nextRunReason = null;
1705
1188
  if (!regressionResult.next_run) {
1706
1189
  if (triggers.length === 0) nextRunReason = 'no_regression_triggers_declared';
@@ -1730,18 +1213,9 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, run
1730
1213
  };
1731
1214
  }
1732
1215
 
1733
- /**
1734
- * Extended interval parser. Supports:
1735
- * <N>d — N days
1736
- * <N>wk — N weeks
1737
- * <N>mo — N calendar months (Date.setMonth semantics)
1738
- * <N>yr — N calendar years
1739
- * on_event — event-triggered, no date computed; surfaces in
1740
- * regression_event_triggers[] for the consumer.
1741
- * Without all five forms, a playbook declaring "regression on every
1742
- * release" or
1743
- * "monthly review" lost its schedule entry.
1744
- */
1216
+ // `<N>d`, `<N>wk`, `<N>mo` and `<N>yr` return a date, months and years by
1217
+ // Date.setMonth / setFullYear semantics. `on_event` computes no date and
1218
+ // surfaces in regression_event_triggers[]; anything else is { unparseable }.
1745
1219
  function parseInterval(intervalStr, now) {
1746
1220
  if (!intervalStr || typeof intervalStr !== 'string') return null;
1747
1221
  const s = intervalStr.trim();
@@ -1774,9 +1248,8 @@ function computeRegressionNextRun(triggers) {
1774
1248
  const parsed = parseInterval(t.interval, now);
1775
1249
  if (!parsed) continue;
1776
1250
  if (parsed.event) {
1777
- // Shipped playbooks key the trigger string as `condition`; `trigger` /
1778
- // `event` are accepted for external/fixture submissions. Reading only
1779
- // the latter two left regression_event_triggers[].trigger always null.
1251
+ // Shipped playbooks key the trigger string as `condition`; `trigger` and
1252
+ // `event` are the spellings external submissions and fixtures use.
1780
1253
  eventTriggers.push({ interval: t.interval, trigger: t.trigger || t.event || t.condition || null });
1781
1254
  continue;
1782
1255
  }
@@ -1793,51 +1266,28 @@ function computeRegressionNextRun(triggers) {
1793
1266
  };
1794
1267
  }
1795
1268
 
1796
- // --- phase 7: close ---
1797
-
1798
- /**
1799
- * Assemble the closure artifacts:
1800
- * - evidence_package (CSAF-2.0 shaped if requested; signed if signing key present)
1801
- * - learning_loop lesson template populated with current finding context
1802
- * - notification_actions with computed ISO 8601 deadlines from clock_starts + window_hours
1803
- * - exception_generation auditor-ready language if trigger fires
1804
- * - regression_schedule.next_run from validate.regression_next_run
1805
- * - feeds_into chaining suggestions
1806
- */
1269
+ // Phase 7. The closure artifacts: the evidence_package (signed when a session
1270
+ // key is present), the learning_loop lesson, notification_actions with deadlines
1271
+ // from clock_starts + window_hours, the auditor-ready exception language, the
1272
+ // regression schedule and the feeds_into chaining suggestions.
1807
1273
  function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
1808
1274
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
1809
1275
  const c = resolvedPhase(playbook, directiveId, 'close');
1810
1276
  const g = resolvedPhase(playbook, directiveId, 'govern');
1811
- // F2/F9: run() generates session_id once and threads it via runOpts.session_id.
1812
- // Pre-fix, close() generated its own session_id independently of run()'s,
1813
- // so CSAF tracking.id, OpenVEX @id, the attestation file name on disk, and
1814
- // the run()-returned session_id were all different hex strings — operators
1815
- // couldn't correlate the attestation file with the bundle URN inside it.
1816
- // crypto.randomBytes() fallback only fires for direct close() calls that
1817
- // bypass run() (e.g. unit tests).
1277
+ // run() mints session_id once and threads it here, so CSAF tracking.id, the
1278
+ // OpenVEX @id and the on-disk attestation name are one identifier.
1818
1279
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
1819
1280
 
1820
- // v0.12.27: when opt-in deterministic bundle mode is set, resolve the
1821
- // single frozen epoch used by every timestamp surface below. Cached for
1822
- // the whole close() call so notification_actions, regression_schedule,
1823
- // and the bundle emitter all agree on the same Date.
1281
+ // One frozen epoch for every timestamp surface below, so notification_actions,
1282
+ // regression_schedule and the bundle emitter agree on the same instant.
1824
1283
  const deterministic = runOpts.bundleDeterministic === true;
1825
1284
  const frozenEpoch = deterministic ? resolveFrozenEpoch(runOpts, playbook) : null;
1826
1285
 
1827
- // notification_actions — compute ISO deadlines from clock_starts events.
1828
- // v0.11.12 (#123): enrich each entry with the matched obligation's
1829
- // jurisdiction/regulation/window_hours/evidence_required fields. The
1830
- // playbook's notification_actions entry only carries `obligation_ref` +
1831
- // `draft_notification` + `recipient`; without enrichment, operators reading
1832
- // `jurisdiction_notifications[i].jurisdiction` got `undefined`. The
1833
- // upstream `govern.jurisdiction_obligations` has the real data — carry it
1834
- // forward. `notification_deadline` is published as an alias for `deadline`
1835
- // (matches the field name compliance teams expect on a notification record).
1836
- // Which engine phases completed in this run. analyze_complete /
1837
- // validate_complete jurisdictional clocks auto-start (under --ack) only when
1838
- // their named phase actually ran — by the time close() executes, a
1839
- // populated analyzeResult / validateResult proves the phase completed in the
1840
- // same synchronous pass.
1286
+ // A playbook's notification_actions entry carries only obligation_ref,
1287
+ // recipient and draft_notification; jurisdiction, regulation, window_hours
1288
+ // and evidence_required merge in from the govern obligation below. The
1289
+ // analyze_complete and validate_complete clocks auto-start only when a
1290
+ // populated result proves their phase ran in this pass.
1841
1291
  const phaseFlags = {
1842
1292
  analyze_complete: !!(analyzeResult && typeof analyzeResult === 'object'),
1843
1293
  validate_complete: !!(validateResult && typeof validateResult === 'object'),
@@ -1847,29 +1297,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1847
1297
  o && typeof o === 'object' &&
1848
1298
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1849
1299
  );
1850
- // A non-empty obligation_ref that resolves to nothing leaves the
1851
- // jurisdiction / regulation / deadline fields null below — surface it as a
1852
- // runtime_error so the unmatched ref is observable, not a silent null record.
1300
+ // An unresolved obligation_ref would leave the record's fields all null.
1853
1301
  if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref &&
1854
1302
  Array.isArray(runOpts && runOpts._runErrors)) {
1855
1303
  pushRunError(runOpts._runErrors, { kind: 'unresolved_obligation_ref', obligation_ref: na.obligation_ref }, { dedupeKey: x => x.obligation_ref || '' });
1856
1304
  }
1857
- // Thread runOpts + the engine-computed classification through so
1858
- // computeClockStart can check operator_consent.explicit before
1859
- // auto-stamping detect_confirmed, and so an engine-confirmed detection
1860
- // starts the clock even without a separately-submitted classification.
1305
+ // computeClockStart checks operator_consent.explicit before auto-stamping,
1306
+ // and takes the engine classification so an engine-confirmed detection
1307
+ // starts the clock without a separately submitted one.
1861
1308
  const engineClassification = analyzeResult?._detect_classification || null;
1862
1309
  const clockStart = obligation
1863
1310
  ? computeClockStart(obligation.clock_starts, agentSignals, runOpts, engineClassification, phaseFlags, frozenEpoch)
1864
1311
  : null;
1865
- // A valid clock is a real Date whose getTime() is finite. computeClockStart
1866
- // already returns null on an unparseable operator timestamp, but guard the
1867
- // arithmetic below independently so no caller (or future code path) can
1868
- // ever reach new Date(NaN).toISOString() and crash the close phase.
1312
+ // Guarded independently of computeClockStart so no path reaches
1313
+ // new Date(NaN).toISOString() and crashes the close phase.
1869
1314
  const clockValid = clockStart instanceof Date && !Number.isNaN(clockStart.getTime());
1870
- // Surface clock_pending_ack when an auto-startable event was confirmed but
1871
- // the operator did NOT pass --ack, so the notification record is visibly
1872
- // waiting on acknowledgement rather than silently stalled.
1315
+ // An auto-startable event that fired without --ack leaves the record
1316
+ // visibly waiting on acknowledgement rather than silently stalled.
1873
1317
  const autoStartEvent = obligation
1874
1318
  && (obligation.clock_starts === 'detect_confirmed'
1875
1319
  || obligation.clock_starts === 'analyze_complete'
@@ -1884,54 +1328,39 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1884
1328
  && autoStartEvent
1885
1329
  && eventReady
1886
1330
  && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
1887
- // window_hours must be a finite number before it enters the deadline
1888
- // arithmetic — a malformed obligation with an undefined/null/non-number
1889
- // window_hours would otherwise compute `getTime() + NaN` and crash the
1890
- // close phase at `new Date(NaN).toISOString()`. Runtime validation of the
1891
- // playbook is not enforced, so guard here independently of the schema.
1331
+ // The playbook is not schema-validated at runtime, and a non-number
1332
+ // window_hours computes `getTime() + NaN`, crashing close().
1892
1333
  const windowValid = obligation && typeof obligation.window_hours === 'number' && Number.isFinite(obligation.window_hours);
1893
1334
  const deadline = obligation && clockValid && windowValid
1894
1335
  ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
1895
1336
  : 'pending_clock_start_event';
1896
- // A notification_action whose obligation_ref was specified but resolves to
1897
- // no obligation is an internally-inconsistent playbook (the schema requires
1898
- // obligation_ref to name a real govern obligation). The unmatched ref is
1899
- // already surfaced as a runtime_error above; drop the record rather than
1900
- // emit a null-jurisdiction/regulation entry that pollutes the deadline list.
1337
+ // Already a runtime_error above; the record is dropped rather than
1338
+ // polluting the deadline list with a null-jurisdiction entry.
1901
1339
  if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref) {
1902
1340
  return null;
1903
1341
  }
1904
1342
  return {
1905
1343
  ...na,
1906
- // Carry obligation metadata forward so each notification entry is
1907
- // operationally usable on its own (calendar deadlines, regulator
1908
- // routing, evidence checklist).
1344
+ // Each entry must stand alone: deadline, routing, evidence checklist.
1909
1345
  jurisdiction: obligation?.jurisdiction || null,
1910
1346
  regulation: obligation?.regulation || null,
1911
1347
  obligation_type: obligation?.obligation || null,
1912
1348
  window_hours: obligation?.window_hours ?? null,
1913
1349
  clock_start_event: obligation?.clock_starts || null,
1914
- // Use the validity gate, not optional-chaining: optional-chaining only
1915
- // short-circuits null/undefined, so a (hypothetical) Invalid Date would
1916
- // still reach .toISOString() and throw. clockValid guarantees a finite
1917
- // instant before we serialize.
1350
+ // clockValid, not optional chaining: `?.` only short-circuits
1351
+ // null/undefined, so an Invalid Date would still reach .toISOString().
1918
1352
  clock_started_at: clockValid ? clockStart.toISOString() : null,
1919
1353
  ...(clockPendingAck ? { clock_pending_ack: true } : {}),
1920
1354
  deadline,
1921
1355
  // Alias matching compliance-team vocabulary.
1922
1356
  notification_deadline: deadline,
1923
- // Evidence the regulator expects attached (from the obligation, not
1924
- // just the operator-facing recipient bundle on the notification entry).
1357
+ // What the regulator expects attached, per the obligation.
1925
1358
  evidence_required: obligation?.evidence_required || na.evidence_attached || [],
1926
- // Track missing interpolation variables so operators see exactly
1927
- // which template vars failed to resolve. Empty array when all
1928
- // placeholders rendered cleanly.
1359
+ // Which template vars failed to resolve; empty when all rendered.
1929
1360
  ...(function () {
1930
1361
  const missing = [];
1931
- // analyzeFindingShape is a pure transform but defensive-wrap it
1932
- // so a malformed analyze result (missing matched_cves, etc.)
1933
- // can't bring down the whole close phase. Failures surface in
1934
- // runtime_errors via runOpts._runErrors when available.
1362
+ // Wrapped so a malformed analyze result cannot bring down close();
1363
+ // the failure surfaces through runOpts._runErrors instead.
1935
1364
  let findingShape;
1936
1365
  try { findingShape = analyzeFindingShape(analyzeResult); }
1937
1366
  catch (e) {
@@ -1949,27 +1378,17 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1949
1378
  })(),
1950
1379
  };
1951
1380
  };
1952
- // enrichNotification returns null for an unresolved specified obligation_ref
1953
- // (already surfaced as a runtime_error); filter those out so the notification
1954
- // list never carries a null-jurisdiction record.
1955
1381
  const notificationActions = (c.notification_actions || []).map(enrichNotification).filter(Boolean);
1956
1382
 
1957
- // A govern obligation that declares a notification duty but has no
1958
- // matching close.notification_actions entry would otherwise never surface
1959
- // in the phase-7 record set — its regulatory clock stays invisible to
1960
- // operators reading jurisdiction_notifications. Synthesize a record for
1961
- // each uncovered notify-type obligation so the deadline is computed and
1962
- // visible. The synthesized entry carries no draft template (the playbook
1963
- // declared none) and is marked synthesized_from_obligation so operators
1964
- // can tell it apart from a playbook-authored action.
1383
+ // A notify obligation with no matching close.notification_actions entry would
1384
+ // leave its regulatory clock invisible. The synthesized record carries no
1385
+ // draft and is marked synthesized_from_obligation.
1965
1386
  const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
1966
1387
  for (const o of (g.jurisdiction_obligations || [])) {
1967
1388
  if (!o || typeof o !== 'object') continue; // skip a null/malformed obligation rather than crash close() during synthesis
1968
1389
  if (!String(o.obligation || '').startsWith('notify')) continue;
1969
- // A notify obligation with a non-number window_hours is malformed: it would
1970
- // synthesize a "…/… undefinedh" ref and could not produce a real deadline.
1971
- // Surface it as a runtime_error and skip synthesis rather than emit a bogus
1972
- // record (the deadline guard in enrichNotification also prevents the crash).
1390
+ // A non-number window_hours would synthesize a "…/… undefinedh" ref that
1391
+ // can never produce a deadline; surface it and skip the synthesis.
1973
1392
  if (typeof o.window_hours !== 'number' || !Number.isFinite(o.window_hours)) {
1974
1393
  if (Array.isArray(runOpts && runOpts._runErrors)) {
1975
1394
  pushRunError(runOpts._runErrors, { kind: 'malformed_obligation_window_hours', obligation: `${o.jurisdiction}/${o.regulation}` }, { dedupeKey: x => x.obligation || '' });
@@ -1987,23 +1406,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1987
1406
  if (synthesized) notificationActions.push(synthesized);
1988
1407
  }
1989
1408
 
1990
- // exception_generation — evaluate trigger.
1991
1409
  let exception = null;
1992
1410
  if (c.exception_generation) {
1993
1411
  const closeEvalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
1994
1412
  const triggered = evalCondition(c.exception_generation.trigger_condition, closeEvalCtx, playbook);
1995
1413
  if (triggered) {
1996
1414
  const t = c.exception_generation.exception_template;
1997
- // Track unresolved ${placeholder} tokens the SAME way the draft_notification
1998
- // render does (missing_interpolation_vars). Exception-template tokens are
1999
- // operator-fill values (affected_host_count, compensating_controls,
2000
- // patch_available_status, …) that analyzeFindingShape does NOT supply and
2001
- // that operators rarely pre-stage on the standard `exceptd run` path, so the
2002
- // auditor-ready risk-acceptance language otherwise ships literal
2003
- // `<MISSING:…>` tokens with no machine-readable signal of which placeholders
2004
- // failed. Surface them as exception.missing_interpolation_vars (empty when
2005
- // all resolved) so a `ci`/JSON consumer — and the operator filling the
2006
- // exception — sees exactly what still needs a value.
1415
+ // Exception-template tokens are operator-fill values analyzeFindingShape
1416
+ // does not supply, so the auditor-ready language routinely renders
1417
+ // `<MISSING:…>`. missing_interpolation_vars names which.
2007
1418
  const exMissing = [];
2008
1419
  const findingShape = (() => { try { return analyzeFindingShape(analyzeResult); } catch { return {}; } })();
2009
1420
  exception = {
@@ -2017,16 +1428,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2017
1428
  framework_id: playbook.domain.frameworks_in_scope[0] || 'unspecified',
2018
1429
  control_id: analyzeResult.framework_gap_mapping?.[0]?.claimed_control || 'unspecified',
2019
1430
  ciso_name: agentSignals.ciso_name || '<CISO NAME>',
2020
- // v0.12.27: deterministic mode roots acceptance_date in the
2021
- // frozen epoch so two runs against the same evidence emit the
2022
- // same auditor-facing date.
1431
+ // Deterministic mode roots the auditor-facing date in the frozen
1432
+ // epoch, so two runs over the same evidence agree.
2023
1433
  acceptance_date: (deterministic ? frozenEpoch : new Date().toISOString()).slice(0, 10),
2024
1434
  duration_expiry: agentSignals.duration_expiry || 'until vendor patch'
2025
1435
  }, exMissing),
2026
1436
  missing_interpolation_vars: exMissing,
2027
1437
  };
2028
- // Make the unresolved-placeholder gap observable to JSON/ci consumers, not
2029
- // just present on the exception object.
1438
+ // Also on runtime_errors, so JSON/ci consumers see the gap without
1439
+ // walking into the exception object.
2030
1440
  if (exMissing.length && Array.isArray(runOpts._runErrors)) {
2031
1441
  pushRunError(runOpts._runErrors,
2032
1442
  { kind: 'exception_unresolved_placeholders', placeholders: exMissing.slice() },
@@ -2035,26 +1445,15 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2035
1445
  }
2036
1446
  }
2037
1447
 
2038
- // evidence_package — playbook declares one primary bundle_format; the
2039
- // operator may request additional formats via agentSignals._bundle_formats
2040
- // (e.g. SARIF for GitHub Code Scanning + OpenVEX for supply-chain tooling
2041
- // alongside the CSAF default).
2042
1448
  const primaryFormat = c.evidence_package?.bundle_format || 'csaf-2.0';
2043
1449
  const extraFormats = Array.isArray(agentSignals._bundle_formats)
2044
1450
  ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
2045
1451
  : [];
2046
- // Build every bundle once and reuse, so bundle_body and
2047
- // bundles_by_format[primary] share object identity (and timestamps).
2048
- // Without memoisation, buildEvidenceBundle gets invoked twice for the
2049
- // primary format and each invocation crystallises a fresh Date.now() —
2050
- // operators diffing bundle_body against bundles_by_format.<primary> see
2051
- // spurious millisecond drift on tracking.initial_release_date /
2052
- // timestamp / current_release_date.
1452
+ // Each bundle is built once, so bundle_body and bundles_by_format[primary]
1453
+ // share identity — a second build would crystallise a fresh Date.now().
2053
1454
  const evidencePackage = c.evidence_package ? (() => {
2054
- // v0.12.27: deterministic mode pins issuedAt to the frozen epoch so
2055
- // CSAF tracking.{initial_release_date,current_release_date,
2056
- // generator.date,revision_history[0].date} and OpenVEX timestamp +
2057
- // statements[].timestamp all collapse to a single, byte-stable value.
1455
+ // Deterministic mode pins issuedAt so every CSAF tracking date and every
1456
+ // OpenVEX timestamp collapses to one byte-stable value.
2058
1457
  const issuedAt = deterministic ? frozenEpoch : new Date().toISOString();
2059
1458
  const builtFormats = new Map();
2060
1459
  const buildOnce = (format) => {
@@ -2064,12 +1463,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2064
1463
  return builtFormats.get(format);
2065
1464
  };
2066
1465
  const primaryBody = buildOnce(primaryFormat);
2067
- // bundles_by_format must always be an object keyed by the
2068
- // primary format, even when no extra formats were requested. Pre-fix it
2069
- // was null in the single-format case, forcing downstream tooling into a
2070
- // `bundles_by_format ?? { [primaryFormat]: bundle_body }` shim in every
2071
- // consumer. Now the field is canonically present so iteration is
2072
- // uniform across single- and multi-format emissions.
1466
+ // Always an object keyed by the primary format, even for a single format,
1467
+ // so consumers iterate uniformly instead of shimming a null.
2073
1468
  const byFormat = Object.fromEntries(
2074
1469
  [primaryFormat, ...extraFormats].map(f => [f, buildOnce(f)])
2075
1470
  );
@@ -2095,7 +1490,6 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2095
1490
  evidencePackage.signature_pending = 'No session_key provided. Sign with Ed25519 via `node $(exceptd path)/lib/sign.js sign-evidence <bundle.json>` post-emit (contributor checkout) or `exceptd doctor --fix` to enable signing.';
2096
1491
  }
2097
1492
 
2098
- // learning_loop lesson
2099
1493
  const lesson = c.learning_loop?.enabled ? {
2100
1494
  enabled: true,
2101
1495
  attack_vector: interpolate(c.learning_loop.lesson_template.attack_vector, analyzeFindingShape(analyzeResult)),
@@ -2106,19 +1500,12 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2106
1500
  proposed_for_zeroday_lessons_id: `lesson-${playbook._meta.id}-${sessionId}`
2107
1501
  } : { enabled: false };
2108
1502
 
2109
- // regression_schedule
2110
- //
2111
- // v0.12.27: deterministic mode re-derives next_run from the frozen epoch
2112
- // rather than wall-clock-now-at-validate-time. Without this, two runs
2113
- // against the same evidence diverge on next_run by the interval between
2114
- // the two `validate()` invocations. Frozen base + the same interval set
2115
- // = byte-identical schedule.
1503
+ // Re-derived from the frozen epoch: taken from wall-clock-at-validate-time,
1504
+ // two runs diverge by the interval between their validate() calls.
2116
1505
  const regressionSchedule = c.regression_schedule ? (() => {
2117
1506
  let nextRun = validateResult.regression_next_run;
2118
1507
  if (deterministic) {
2119
- // Re-derive against the validate phase's trigger set (not the
2120
- // close phase's regression_schedule subtree — close has no triggers
2121
- // of its own, just the canonical interval declared upstream).
1508
+ // Against the validate phase's triggers — close declares none of its own.
2122
1509
  const v = resolvedPhase(playbook, directiveId, 'validate');
2123
1510
  nextRun = frozenRegressionNextRun(v.regression_trigger || [], new Date(frozenEpoch));
2124
1511
  }
@@ -2129,69 +1516,35 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2129
1516
  };
2130
1517
  })() : null;
2131
1518
 
2132
- // feeds_into chaining — full analyze result is exposed so conditions can
2133
- // reference `analyze.compliance_theater_check.verdict` etc.
1519
+ // The whole analyze result, so conditions can reference `analyze.*` paths.
2134
1520
  const feedsCtx = {
2135
- // Fired detect indicators (verdict 'hit') mirrored in so a feeds_into
2136
- // condition written as `<indicator-id> == true` resolves on the standard
2137
- // collector path (signal_overrides), not only when the id is re-submitted
2138
- // under signals. Lowest precedence — operator signals and engine values win.
1521
+ // Lowest precedence, so a feeds_into condition written as
1522
+ // `<indicator-id> == true` resolves on the standard collector path.
2139
1523
  ...firedIndicatorSignals(analyzeResult && analyzeResult._detect_indicators),
2140
- // Operator-submitted signals come FIRST so the engine-computed reserved
2141
- // keys below always win — otherwise a submitted signals.rwep / finding /
2142
- // analyze would override the engine value the feeds_into condition tests
2143
- // and could suppress a legitimate downstream chain.
1524
+ // Operator signals FIRST so the engine keys below win: a submitted
1525
+ // signals.rwep must not override the value the condition tests.
2144
1526
  ...agentSignals,
2145
1527
  rwep: analyzeResult.rwep?.adjusted,
2146
- // Bare-token parity with the escalation context (analyze()): catalog
2147
- // feeds_into conditions reference the unqualified tokens `blast_radius_score`
2148
- // and `theater_verdict` (e.g. framework.json's feeds_into into sbom). Without
2149
- // these top-level keys resolvePath returns null and `null >= 4` is false
2150
- // regardless of the engine-computed blast radius, so the chain is dead. They
2151
- // are spread AFTER ...agentSignals so the engine value wins over any
2152
- // operator-submitted signals.blast_radius_score / signals.theater_verdict.
1528
+ // Bare-token parity with the escalation context: catalog conditions write
1529
+ // these unqualified, and without the top-level keys resolvePath returns
1530
+ // null, so `null >= 4` kills the chain whatever the engine computed.
2153
1531
  blast_radius_score: analyzeResult.blast_radius_score,
2154
1532
  theater_verdict: analyzeResult.compliance_theater_check?.verdict,
2155
- // Bare top-level `compliance_theater_check` so catalog conditions that
2156
- // reference `compliance_theater_check.verdict` unqualified (framework.json's
2157
- // feeds_into into sbom) resolve — without it resolvePath returns null and the
2158
- // clause is dead regardless of the engine verdict. The `analyze.*` alias
2159
- // below stays for conditions that use the qualified path.
2160
1533
  compliance_theater_check: analyzeResult.compliance_theater_check,
2161
- // Govern-phase jurisdiction obligations so feeds_into conditions like
2162
- // `… AND jurisdiction_obligations contains 'EU'` (framework → sbom) resolve.
2163
1534
  jurisdiction_obligations: (g && g.jurisdiction_obligations) || (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [],
2164
- // theater_score follows lib/framework-gap.js's convention: HIGH = more
2165
- // theater detected (worse). A 'theater' verdict (a gap exists) is the
2166
- // concerning case, so it scores 100; a clear verdict scores 0. (Earlier
2167
- // this was inverted, so a feeds_into condition like `theater_score >= 50`
2168
- // would have failed to fire exactly when a gap was found.)
2169
- // 'present' is an allowlisted theater-equivalent verdict (the same
2170
- // gap-present set verdict_text uses at line 1426 and the allowlist comment
2171
- // names at line 1352-1353), so it must score 100 (gap detected = worse), not
2172
- // 0. Scoring only the 'theater' spelling left a 'present' verdict scored as
2173
- // clear — inverted for any future feeds_into condition gating on theater_score.
1535
+ // theater_score follows lib/framework-gap.js: high means more theater, so a
1536
+ // gap-present verdict scores 100 and 'clear' scores 0. 'present' is the
1537
+ // allowlisted synonym for 'theater' and must score with it.
2174
1538
  theater_score: (analyzeResult.compliance_theater_check?.verdict === 'theater' || analyzeResult.compliance_theater_check?.verdict === 'present') ? 100 : 0,
2175
- // Top-level matched_cve array so the shipped sbom feeds_into quantifiers
2176
- // (`any matched_cve.attack_class == 'kernel-lpe'`, … IN ['ai-c2', …]) re-root
2177
- // at each matched CVE. Without it the quantifier head resolves null and the
2178
- // sbom -> kernel/mcp/ai-api deep-dive chains stay dead even when a matched
2179
- // CVE carries the attack_class. The `analyze.matched_cves` alias stays for
2180
- // conditions that use the qualified path.
1539
+ // The head the sbom feeds_into quantifiers re-root at; without the
1540
+ // top-level key it resolves null and the deep-dive chains stay dead.
2181
1541
  matched_cve: analyzeResult.matched_cves || [],
2182
1542
  analyze: analyzeResult,
2183
1543
  validate: validateResult,
2184
- // finding.* is two-sourced: the engine computes the CVE/severity-derived keys
2185
- // via analyzeFindingShape; the DESCRIPTIVE keys the catalog feeds_into
2186
- // conditions gate on (finding.includes_*, cve_class, tool_surface,
2187
- // mcp_server_location, pipeline_credentials_in_scope, …) are host-AI-asserted.
2188
- // Merge the agent-supplied finding sub-fields UNDER the engine shape so the
2189
- // descriptive keys survive while engine-owned keys WIN on collision. Same
2190
- // shape + guards as the escalation ctx above.
1544
+ // Same two-sourced finding.* shape and guards as the escalation context.
2191
1545
  finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...analyzeFindingShape(analyzeResult) },
2192
- // Surface evalCondition regex failures from the feeds_into chain into
2193
- // the same accumulator. Without this the regex failure happens but
2194
- // analyze.runtime_errors[] never sees it.
1546
+ // Without the accumulator, a feeds_into condition failure never reaches
1547
+ // analyze.runtime_errors[].
2195
1548
  ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
2196
1549
  };
2197
1550
  const feeds = (playbook._meta.feeds_into || [])
@@ -2205,32 +1558,22 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2205
1558
  evidence_package: evidencePackage,
2206
1559
  learning_loop: lesson,
2207
1560
  notification_actions: notificationActions,
2208
- // v0.11.10 (#104): operators expected the field name
2209
- // `jurisdiction_notifications`. Surface it as an alias for the full
2210
- // notification_actions list, plus a `jurisdiction_clocks_count` that
2211
- // mirrors `ci.summary.jurisdiction_clocks_started` — the count of
2212
- // notifications whose clock has actually started (clock_started_at != null).
1561
+ // Alias for notification_actions. jurisdiction_clocks_count mirrors
1562
+ // ci.summary.jurisdiction_clocks_started — those whose clock has started.
2213
1563
  jurisdiction_notifications: notificationActions,
2214
1564
  jurisdiction_clocks_count: notificationActions.filter(n => n && n.clock_started_at != null).length,
2215
1565
  exception: exception,
2216
1566
  regression_schedule: regressionSchedule,
2217
1567
  feeds_into: feeds,
2218
- // feeds_into surfaces downstream playbook IDs whose preconditions
2219
- // were satisfied by this run. The runner does NOT automatically chain
2220
- // into them — the agent / operator decides whether to invoke them.
2221
- // Surface that contract on the result so consumers don't assume an
2222
- // automated handoff happened.
1568
+ // The runner never chains into feeds_into itself; the agent or operator
1569
+ // decides. Stated on the result so no consumer assumes a handoff happened.
2223
1570
  feeds_into_auto_chained: false,
2224
1571
  };
2225
1572
  }
2226
1573
 
2227
- // Severity ladder for active_exploitation. The worst-of reduction lets
2228
- // analyzeFindingShape report the most-exploited CVE in the matched set, not
2229
- // the first-encountered one. Higher index = worse.
2230
- // `theoretical` (PoC exists, no in-the-wild use) must rank between `none` and
2231
- // `unknown`; omitting it made `?? -1` lose to the -1 start, so an all-theoretical
2232
- // matched set wrongly reduced to 'unknown' and a theoretical+none set dropped
2233
- // the theoretical entry entirely. This vocabulary is first-class in scoring.js.
1574
+ // Severity ladder for active_exploitation, higher being worse. Every value in
1575
+ // scoring.js's vocabulary must appear — an omitted one falls to `?? -1`, loses
1576
+ // to the -1 start, and drops out of the reduction entirely.
2234
1577
  const ACTIVE_EXPLOITATION_RANK = { none: 0, theoretical: 1, unknown: 2, suspected: 3, confirmed: 4 };
2235
1578
 
2236
1579
  function worstActiveExploitation(matchedCves) {
@@ -2242,19 +1585,13 @@ function worstActiveExploitation(matchedCves) {
2242
1585
  const rank = ACTIVE_EXPLOITATION_RANK[v] ?? -1;
2243
1586
  if (rank > worstRank) { worst = v; worstRank = rank; }
2244
1587
  }
2245
- // Empty / all-unrecognized matched set → 'none' (a draft must not assert
2246
- // 'unknown' exploitation it never observed).
1588
+ // An empty or all-unrecognized set is 'none': a draft must not assert
1589
+ // 'unknown' exploitation it never observed.
2247
1590
  return worst || 'none';
2248
1591
  }
2249
1592
 
2250
- // Severity ladder derived from rwep_adjusted. Playbooks reference
2251
- // `finding.severity` in feeds_into and escalation_criteria conditions;
2252
- // emit it so those conditions resolve against a real value rather than
2253
- // undefined. Thresholds:
2254
- // rwep >= 80 → critical
2255
- // rwep >= 50 → high
2256
- // rwep >= 20 → medium
2257
- // rwep < 20 → low
1593
+ // The value playbooks gate on as `finding.severity` in feeds_into and
1594
+ // escalation_criteria; without it those conditions resolve undefined.
2258
1595
  function severityForRwep(rwep) {
2259
1596
  const r = typeof rwep === 'number' ? rwep : 0;
2260
1597
  if (r >= 80) return 'critical';
@@ -2268,23 +1605,17 @@ function analyzeFindingShape(a) {
2268
1605
  const rwepAdjusted = a.rwep?.adjusted ?? 0;
2269
1606
  return {
2270
1607
  matched_cve_ids: matched.map(c => c.cve_id).join(', '),
2271
- // Sibling array form for consumers that want to iterate IDs without
2272
- // re-splitting the joined string. The joined form stays for backwards
2273
- // compatibility with notification-draft templates that interpolate
2274
- // `${matched_cve_ids}` verbatim.
1608
+ // The joined form is what notification-draft templates interpolate as
1609
+ // `${matched_cve_ids}`; this is for consumers that want to iterate.
2275
1610
  matched_cve_ids_array: matched.map(c => c.cve_id),
2276
1611
  matched_cve_count: matched.length,
2277
1612
  kev_listed_count: matched.filter(c => c.cisa_kev).length,
2278
- // Reduce active_exploitation to the worst rank across all matched
2279
- // CVEs. A .find() lookup would return the first truthy entry — e.g.
2280
- // 'suspected' on CVE #1 when CVE #2 is 'confirmed' — under-stating
2281
- // the threat in notification drafts.
2282
- // Exclude VEX-fixed (vendor-patched) CVEs: a notification draft must not
2283
- // assert active exploitation sourced from an already-remediated CVE.
1613
+ // Worst rank, not the first truthy entry: a draft reporting 'suspected'
1614
+ // while another CVE is 'confirmed' understates the threat. VEX-fixed CVEs
1615
+ // are excluded — a draft must not source exploitation from a fixed CVE.
2284
1616
  active_exploitation: worstActiveExploitation(matched.filter(c => c.vex_status !== 'fixed')),
2285
1617
  rwep_adjusted: rwepAdjusted,
2286
1618
  rwep_base: a.rwep?.base ?? 0,
2287
- // Severity surface for playbook conditions.
2288
1619
  severity: severityForRwep(rwepAdjusted),
2289
1620
  blast_radius_score: a.blast_radius_score ?? 0,
2290
1621
  framework_id_first: a.framework_gap_mapping?.[0]?.framework || null,
@@ -2292,18 +1623,10 @@ function analyzeFindingShape(a) {
2292
1623
  };
2293
1624
  }
2294
1625
 
2295
- // Map a vulnerability identifier to its issuing authority + the canonical
2296
- // human-readable advisory URL for that authority. CVE ids resolve to NVD;
2297
- // GHSA/OSV/RUSTSEC/SNYK each have their own advisory database. A MAL- malicious
2298
- // -package id has no public per-id advisory page, so helpUri is null (the id is
2299
- // still labelled with its system_name). An unrecognised prefix resolves to a
2300
- // null helpUri rather than a fabricated link.
2301
- //
2302
- // Used by the SARIF rule emitter (helpUri) so non-CVE matched ids no longer
2303
- // get a hardcoded nvd.nist.gov/vuln/detail/<id> URL — that URL 404s for every
2304
- // MAL-/GHSA-/OSV-/RUSTSEC- id and mislabels it as an NVD CVE. The same
2305
- // prefix→authority knowledge lives in the CSAF ids[] branch (csafIdsFor); both
2306
- // derive from this table so the two exports cannot drift.
1626
+ // A vulnerability identifier's issuing authority and canonical advisory URL. An
1627
+ // id with no public per-id page, and an unrecognised prefix, both resolve to a
1628
+ // null helpUri rather than a fabricated link: an nvd.nist.gov URL 404s for a
1629
+ // non-CVE id. The CSAF ids[] branch (csafIdsFor) carries the same knowledge.
2307
1630
  const CVE_ID_RE = /^CVE-\d{4}-\d{4,}$/;
2308
1631
  function advisoryAuthorityFor(id) {
2309
1632
  if (typeof id !== 'string' || !id) return { system_name: null, helpUri: null };
@@ -2312,21 +1635,17 @@ function advisoryAuthorityFor(id) {
2312
1635
  if (id.startsWith('OSV-')) return { system_name: 'OSV', helpUri: `https://osv.dev/vulnerability/${id}` };
2313
1636
  if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', helpUri: `https://rustsec.org/advisories/${id}.html` };
2314
1637
  if (id.startsWith('SNYK-')) return { system_name: 'Snyk', helpUri: `https://security.snyk.io/vuln/${id}` };
2315
- // Malicious-package ids have no canonical per-id advisory page; label the
2316
- // authority but emit no link rather than a fabricated NVD URL.
2317
1638
  if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', helpUri: null };
2318
1639
  return { system_name: 'exceptd-unknown', helpUri: null };
2319
1640
  }
2320
1641
 
2321
- // Route a vulnerability identifier to its registry-specific URN namespace.
2322
- // CVE-/GHSA-/RUSTSEC-/MAL-* identifiers each have a registered URN namespace;
2323
- // unrecognised prefixes route to the `urn:exceptd:advisory:` private
2324
- // namespace so OpenVEX statements still carry a valid IRI per RFC 8141.
1642
+ // Route a vulnerability identifier to its registered URN namespace, or to the
1643
+ // private `urn:exceptd:advisory:` one, so every OpenVEX statement carries a
1644
+ // valid IRI per RFC 8141.
2325
1645
  function vulnIdToUrn(id) {
2326
1646
  if (typeof id !== 'string' || id.length === 0) return `urn:exceptd:advisory:${urnSlug(id)}`;
2327
- // Registered identifiers keep their canonical case in the NSS so the @id
2328
- // matches the OpenVEX `name` / CSAF id exactly. The private advisory
2329
- // namespace slugs arbitrary text, so it stays lowercase.
1647
+ // Registered identifiers keep their canonical case so the @id matches the
1648
+ // OpenVEX `name` / CSAF id; the private namespace slugs text and lowercases.
2330
1649
  const canonical = urnSlug(id, true);
2331
1650
  if (/^CVE-/i.test(id)) return `urn:cve:${canonical}`;
2332
1651
  if (/^GHSA-/i.test(id)) return `urn:ghsa:${canonical}`;
@@ -2335,26 +1654,16 @@ function vulnIdToUrn(id) {
2335
1654
  return `urn:exceptd:advisory:${urnSlug(id)}`;
2336
1655
  }
2337
1656
 
2338
- // Build a CSAF product_tree.branches[] tree (vendor → product_name →
2339
- // product_version). Sources of vendor/product/version, in priority order:
2340
- // (1) catalog entry `affected_products: [{ vendor, product, version }]`
2341
- // (2) heuristic parse of `affected_components[]` strings — accepts
2342
- // `vendor/product@version` and `vendor product version` shapes.
2343
- // Unparseable component strings emit a `csaf_branch_unparseable` runtime
2344
- // error and are dropped from the tree. Sort alphabetical at each level so
2345
- // the output is deterministic across runs.
2346
- //
2347
- // Returns `{ branches, productIds }`. productIds is a stable enumeration
2348
- // CSAFPID-0..N keyed by (vendor, product, version) insertion order so other
2349
- // emit paths can reference the leaf products by id later.
1657
+ // Build a CSAF product_tree.branches[] from the catalog's `affected_products`
1658
+ // records, falling back to a heuristic parse of `affected_components[]`.
1659
+ // Unparseable components raise `csaf_branch_unparseable` and drop; each level
1660
+ // sorts alphabetically for determinism. Returns { branches, productIds,
1661
+ // cveProductIds }, the last binding each CVE to the leaves it contributed so
1662
+ // the caller can reference them from vulnerabilities[].product_status.
2350
1663
  function buildCsafBranches(matchedCves, runOpts) {
2351
- // Build a (vendor → product → Set<version>) map.
2352
1664
  const tree = new Map();
2353
- // Track which CVE contributed each leaf so the emitted CSAFPID-N product_ids
2354
- // can be bound back into that CVE's vulnerabilities[].product_status — without
2355
- // this the branch tree's per-version products are referenced by nothing and a
2356
- // CSAF consumer correlating product_status to the tree resolves only the
2357
- // generic synthetic product. leafKey = vendor\0product\0version.
1665
+ // Without the per-CVE binding the version leaves are referenced by nothing,
1666
+ // and product_status correlates only to the synthetic product.
2358
1667
  const leafKey = (vendor, product, version) => JSON.stringify([vendor, product, version]);
2359
1668
  const cveLeaves = new Map(); // cve_id → Set<leafKey>
2360
1669
  const addLeaf = (vendor, product, version, cveId) => {
@@ -2369,13 +1678,11 @@ function buildCsafBranches(matchedCves, runOpts) {
2369
1678
  }
2370
1679
  };
2371
1680
 
2372
- // Comparison / range operators that appear between a package name and a
2373
- // version in the catalog's dominant `package OP version` affected_versions
2374
- // shape (e.g. "linux-kernel >= 4.14", "runc <= 1.1.11", "litellm < 1.83.7").
2375
- // These are operators, never package names.
1681
+ // What sits between package and version in the catalog's dominant
1682
+ // `package OP version` shape. These are never package names.
2376
1683
  const RANGE_OP_RE = /^(<=|>=|==|!=|~>|<|>|=|~|\^)$/;
2377
1684
 
2378
- // Heuristic parser. Returns { vendor, product, version } or null.
1685
+ // Returns { vendor, product, version } or null.
2379
1686
  const parseComponentString = (s) => {
2380
1687
  if (typeof s !== 'string' || !s.trim()) return null;
2381
1688
  const trimmed = s.trim();
@@ -2383,27 +1690,19 @@ function buildCsafBranches(matchedCves, runOpts) {
2383
1690
  let m = trimmed.match(/^([^/\s@]+)\/([^/\s@]+)@(.+)$/);
2384
1691
  if (m) return { vendor: m[1], product: m[2], version: m[3].trim() };
2385
1692
  const parts = trimmed.split(/\s+/);
2386
- // `package OP version` — the catalog's dominant shape. The token before
2387
- // the version is a comparison/range operator, so the PACKAGE is the
2388
- // product name and the operator belongs to the version qualifier, not the
2389
- // product_name. Pre-fix this split named the product after the operator
2390
- // ('>=', '<', '=='), corrupting the CSAF affected-product list. Carry the
2391
- // operator into the version string ('>= 4.14') so the range qualifier
2392
- // survives while the product_name stays the real package. Multiple leading
2393
- // operator tokens (a compound range emitted as one string) collapse into
2394
- // the version qualifier too.
1693
+ // `package OP version`. The operator belongs to the version qualifier
1694
+ // ('>= 4.14'), not the product_name — naming the product after '>=' or '<'
1695
+ // corrupts the CSAF affected-product list.
2395
1696
  if (parts.length >= 3 && RANGE_OP_RE.test(parts[1])) {
2396
1697
  const product = parts[0];
2397
1698
  const versionTokens = parts.slice(1);
2398
- // Only accept the shape when the trailing token is an actual version
2399
- // (starts with a digit or v\d) — otherwise it isn't a `package OP version`.
1699
+ // The shape only holds when the trailing token really is a version.
2400
1700
  const lastTok = versionTokens[versionTokens.length - 1];
2401
1701
  if (/^v?\d/.test(lastTok)) {
2402
1702
  return { vendor: product, product, version: versionTokens.join(' ') };
2403
1703
  }
2404
1704
  }
2405
- // `vendor product version` — exactly three whitespace-separated tokens
2406
- // where the last token starts with a digit or `v\d`.
1705
+ // `vendor product version`, where the last token starts with a digit or `v\d`.
2407
1706
  if (parts.length >= 3) {
2408
1707
  const last = parts[parts.length - 1];
2409
1708
  if (/^v?\d/.test(last)) {
@@ -2438,7 +1737,6 @@ function buildCsafBranches(matchedCves, runOpts) {
2438
1737
  }
2439
1738
  }
2440
1739
 
2441
- // Sort + emit.
2442
1740
  const productIds = [];
2443
1741
  const leafKeyToPid = new Map();
2444
1742
  let pidCounter = 0;
@@ -2471,8 +1769,6 @@ function buildCsafBranches(matchedCves, runOpts) {
2471
1769
  }),
2472
1770
  };
2473
1771
  });
2474
- // Per-CVE CSAFPID binding so the caller can add the version leaves to each
2475
- // vulnerability's product_status.known_affected.
2476
1772
  const cveProductIds = {};
2477
1773
  for (const [cveId, keys] of cveLeaves.entries()) {
2478
1774
  const pids = [];
@@ -2482,11 +1778,9 @@ function buildCsafBranches(matchedCves, runOpts) {
2482
1778
  return { branches, productIds, cveProductIds };
2483
1779
  }
2484
1780
 
2485
- // Slugify a string into a URN-safe segment (RFC 8141 NSS). Empty input →
2486
- // 'unknown' so we never emit zero-length segments. preserveCase keeps the
2487
- // canonical case of registered identifiers (e.g. CVE-2026-43284) — the NSS is
2488
- // case-sensitive per RFC 8141, and the OpenVEX `name`/CSAF id fields carry the
2489
- // canonical case, so the URN @id must match rather than fold to lowercase.
1781
+ // Slugify into a URN-safe segment (RFC 8141 NSS); empty input returns
1782
+ // 'unknown', never a zero-length segment. The NSS is case-sensitive, so
1783
+ // preserveCase keeps a registered identifier matching its OpenVEX / CSAF form.
2490
1784
  function urnSlug(s, preserveCase = false) {
2491
1785
  if (s == null) return 'unknown';
2492
1786
  let str = String(s);
@@ -2497,10 +1791,9 @@ function urnSlug(s, preserveCase = false) {
2497
1791
  return slug.length ? slug : 'unknown';
2498
1792
  }
2499
1793
 
2500
- // Build the canonical product binding shared by CSAF + OpenVEX. CSAF's
2501
- // product_tree must declare every product referenced from
2502
- // vulnerabilities[].product_status; OpenVEX statements MUST carry a
2503
- // `products` array per spec §4.3.
1794
+ // The product binding CSAF and OpenVEX share: product_tree must declare every
1795
+ // product product_status references, and an OpenVEX statement MUST carry a
1796
+ // `products` array (spec §4.3).
2504
1797
  function buildProductBinding(playbook, sessionId) {
2505
1798
  const playbookSlug = urnSlug(playbook._meta.id);
2506
1799
  const sessionSlug = urnSlug(sessionId || 'session');
@@ -2513,30 +1806,10 @@ function buildProductBinding(playbook, sessionId) {
2513
1806
  };
2514
1807
  }
2515
1808
 
2516
- // Best-effort SARIF location list for an indicator hit. Indicator records
2517
- // don't carry a direct artifact reference; we fall back to the playbook's
2518
- // look-phase artifact source paths (the inspected files/processes). GitHub
2519
- // Code Scanning hides results without `artifactLocation.uri`, so we
2520
- // surface at least one candidate when any is known. Returns null when no
2521
- // candidate exists — caller MUST omit `locations` rather than emit empty.
2522
- //
2523
- // Source segments are heterogeneous — many playbook artifacts
2524
- // describe a shell-command capture (`uname -r`) or human prose, not a real
2525
- // file or URI. SARIF `artifactLocation.uri` is defined as a URI reference
2526
- // (RFC 3986); shell-command text + prose breaks downstream consumers
2527
- // (GitHub Code Scanning rejects with "invalid URI" or renders garbled).
2528
- // We accept only path-shaped candidates: absolute POSIX paths, `~`-home
2529
- // paths, relative paths, drive-prefixed Windows paths, or file-URI
2530
- // strings. Everything else (commands, English) is dropped, and locations
2531
- // is omitted entirely when no candidate survives.
2532
- // Path-shape predicate: accept anything that begins with a POSIX absolute
2533
- // path (`/...`), home (`~/...` or `~`), relative dot (`./...`, `../...`,
2534
- // or a bare `.`), drive-prefixed Windows path (`C:\...`, `C:/...`), or a
2535
- // `file:` URI. Also accept simple relative names that contain a slash
2536
- // (e.g. `etc/os-release`, `subdir/file.json`) — these are common in
2537
- // playbook artifact source fields. Reject anything with internal
2538
- // whitespace (commands like `uname -r`, prose like `kpatch list || ls
2539
- // /sys/kernel/livepatch`) or that looks like a sentence.
1809
+ // Path-shape predicate for a SARIF artifactLocation.uri candidate. A
1810
+ // look-artifact `source` is as often a shell command or prose as a file, and
1811
+ // SARIF requires an RFC 3986 URI reference — anything with internal whitespace
1812
+ // is a command or a sentence, not a path.
2540
1813
  function looksLikePath(src) {
2541
1814
  if (typeof src !== 'string') return false;
2542
1815
  const trimmed = src.trim();
@@ -2549,20 +1822,16 @@ function looksLikePath(src) {
2549
1822
  if (/^[A-Za-z0-9_.+-]+[/\\][^\s]+$/.test(trimmed)) return true; // bare relative path
2550
1823
  return false;
2551
1824
  }
1825
+ // Physical SARIF locations for an indicator hit, or null — on which the caller
1826
+ // must omit `locations` rather than emit an empty array. Submission-supplied
1827
+ // evidence_locations give a real file; look-artifact sources are the fallback.
2552
1828
  function sarifLocationsForIndicator(playbook, indicator) {
2553
- // Prefer per-indicator evidence locations the submission supplied (threaded
2554
- // onto the firing indicator by detect()). Each entry is a path string or
2555
- // { uri, startLine?, endLine? }. This is what gives SARIF results a real
2556
- // file location instead of the coarse playbook-source fallback below.
2557
1829
  const ev = indicator && Array.isArray(indicator.evidence_locations) ? indicator.evidence_locations : null;
2558
1830
  if (ev && ev.length) {
2559
1831
  const locs = [];
2560
1832
  for (const e of ev) {
2561
- // SARIF artifactLocation.uri is a URI reference (RFC 3986) — the path
2562
- // separator must be `/`. Operator/agent-supplied evidence on Windows
2563
- // arrives with backslashes; normalize them so the emitted SARIF is
2564
- // valid (the collectors already normalize via buildEvidenceLocations,
2565
- // but submission-threaded locations bypass that path).
1833
+ // artifactLocation.uri is an RFC 3986 reference, so the separator must be
1834
+ // `/`; submission-threaded locations bypass the collectors' normalization.
2566
1835
  if (typeof e === "string" && e.trim()) {
2567
1836
  locs.push({ physicalLocation: { artifactLocation: { uri: e.trim().replace(/\\/g, "/") } } });
2568
1837
  } else if (e && typeof e === "object" && typeof e.uri === "string" && e.uri.trim()) {
@@ -2586,27 +1855,18 @@ function sarifLocationsForIndicator(playbook, indicator) {
2586
1855
  return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
2587
1856
  }
2588
1857
 
2589
- // Locations for a finding-class SARIF result, with a guaranteed non-empty
2590
- // fallback. sarifLocationsForIndicator returns a PHYSICAL location only when the
2591
- // agent supplied evidence_locations or the playbook's look-artifact source is a
2592
- // bare path token; the dominant catalog shape is a prose / glob / shell-command
2593
- // source (which carries whitespace and is rejected by looksLikePath), so the
2594
- // physical fallback is null for ~most playbooks. A SARIF result with no
2595
- // `locations[]` is silently DROPPED by GitHub Code Scanning — so a result with
2596
- // no concrete file still gets a `logicalLocations` entry naming its rule, which
2597
- // is SARIF-conformant (§3.33), keeps the finding attributable + visible in the
2598
- // alerts list and in SARIF viewers, and is honest (there is no physical file to
2599
- // point at when the agent supplied no evidence location). Physical location is
2600
- // always preferred when available.
1858
+ // Locations for a finding-class SARIF result, never empty: GitHub Code Scanning
1859
+ // silently DROPS a result with no `locations[]`, and most playbooks have no
1860
+ // physical location to give. A rule-naming `logicalLocations` entry is
1861
+ // SARIF-conformant (§3.33) and honest about there being no file to point at.
2601
1862
  function sarifResultLocations(playbook, indicator, fqRuleId) {
2602
1863
  const phys = sarifLocationsForIndicator(playbook, indicator);
2603
1864
  if (phys && phys.length) return phys;
2604
1865
  return [{ logicalLocations: [{ name: fqRuleId, fullyQualifiedName: fqRuleId, kind: 'rule' }] }];
2605
1866
  }
2606
1867
 
2607
- // Resolve the package version once per process so CSAF tracking.generator
2608
- // can name the engine that emitted the advisory. Best-effort read — bundle
2609
- // emission must not crash if package.json is missing (e.g. exotic install).
1868
+ // The engine version CSAF tracking.generator names, resolved once per process.
1869
+ // Bundle emission must not crash on an install where package.json is unreadable.
2610
1870
  let _CACHED_PKG_VERSION = null;
2611
1871
  function getEngineVersion() {
2612
1872
  if (_CACHED_PKG_VERSION != null) return _CACHED_PKG_VERSION;
@@ -2619,16 +1879,9 @@ function getEngineVersion() {
2619
1879
  return _CACHED_PKG_VERSION;
2620
1880
  }
2621
1881
 
2622
- // v0.12.27: deterministic-bundle epoch resolution. Priority:
2623
- // 1. runOpts.bundleEpoch (operator-supplied --bundle-epoch <ISO>)
2624
- // 2. playbook._meta.last_threat_review (the freshness anchor that already
2625
- // gates every shipped playbook — stable across re-runs of the same
2626
- // catalog version)
2627
- // 3. '1970-01-01T00:00:00Z' fallback (effectively impossible in practice
2628
- // because every shipped playbook carries last_threat_review, but
2629
- // guarantees the deterministic path never crashes on a malformed
2630
- // playbook).
2631
- // Returns a full ISO-8601 timestamp (date-only inputs are normalised).
1882
+ // The deterministic-bundle epoch: runOpts.bundleEpoch, then
1883
+ // playbook._meta.last_threat_review (stable across re-runs of one catalog
1884
+ // version), then 1970 so a malformed playbook cannot crash the path.
2632
1885
  function resolveFrozenEpoch(runOpts, playbook) {
2633
1886
  const raw = runOpts && runOpts.bundleEpoch
2634
1887
  ? runOpts.bundleEpoch
@@ -2638,11 +1891,8 @@ function resolveFrozenEpoch(runOpts, playbook) {
2638
1891
  catch { return '1970-01-01T00:00:00Z'; }
2639
1892
  }
2640
1893
 
2641
- // Recompute regression_schedule.next_run against a frozen `now` so two
2642
- // deterministic-mode runs of the same playbook produce byte-identical
2643
- // schedules. Mirrors computeRegressionNextRun but with an injected base
2644
- // date. Returns the soonest ISO timestamp or null when no interval-based
2645
- // trigger fired.
1894
+ // computeRegressionNextRun against an injected base date, so two
1895
+ // deterministic-mode runs produce byte-identical schedules.
2646
1896
  function frozenRegressionNextRun(triggers, frozenNow) {
2647
1897
  let soonest = null;
2648
1898
  for (const t of (triggers || [])) {
@@ -2653,42 +1903,19 @@ function frozenRegressionNextRun(triggers, frozenNow) {
2653
1903
  return soonest ? soonest.toISOString() : null;
2654
1904
  }
2655
1905
 
2656
- // Operator-supplied identity strings (--operator) and publisher namespace
2657
- // URLs (--publisher-namespace) flow into operator-facing CSAF surfaces.
2658
- // Strip ASCII control characters as defence in depth — bin/exceptd.js
2659
- // already validates the CLI inputs, but the runner is also called from
2660
- // library consumers that may bypass the CLI surface.
2661
- //
2662
- // Strip Unicode bidi / format / control / surrogate / private-use /
2663
- // unassigned categories (\p{C} under the `u` regex flag) so direct
2664
- // library callers of buildEvidenceBundle cannot smuggle a U+202E "RTL
2665
- // OVERRIDE" or zero-width joiner past the sanitiser the way the CLI
2666
- // already refuses. NFC-normalise first so a decomposed sequence can't
2667
- // combine past the codepoint check; cap the result at 256 codepoints
2668
- // (NOT UTF-16 code units) so a string of astral-plane codepoints can't
2669
- // smuggle a longer-than-256-display string past the cap by exploiting
2670
- // JavaScript's surrogate-pair string length. Returns null on rejection
2671
- // (empty after strip, or NFC normalise threw); callers (the
2672
- // publisher-namespace + contact_details + tracking.generator sites)
2673
- // treat null as "operator-unclaimed" and route through the existing
2674
- // fallback (publisher.namespace = urn:exceptd:operator:unknown +
2675
- // bundle_publisher_unclaimed runtime warning).
1906
+ // --operator and --publisher-namespace land on operator-facing CSAF surfaces.
1907
+ // bin/exceptd.js validates the CLI inputs, but a library consumer bypasses it
1908
+ // and must not be able to smuggle a U+202E RTL OVERRIDE or a zero-width joiner
1909
+ // into a bundle. Null on rejection, which callers treat as operator-unclaimed.
2676
1910
  function sanitizeOperatorText(s) {
2677
1911
  if (typeof s !== 'string') return null;
2678
- // NFC first: a Cf codepoint may be expressed as a base + combining mark
2679
- // that recomposes into the format category under NFC. Normalise so the
2680
- // strip catches it.
1912
+ // NFC first: a Cf codepoint can arrive as a base plus combining mark that
1913
+ // only recomposes into the format category under normalisation.
2681
1914
  let normalised;
2682
1915
  try { normalised = s.normalize('NFC'); }
2683
1916
  catch { return null; }
2684
- // Two-pass strip. First remove the named threat families (bidi-override /
2685
- // C0-control / zero-width / null) via the shared vendored codepoint tables,
2686
- // so the family vocabulary has a single source of truth. Then strip any
2687
- // remaining General Category C codepoint: \p{C} (Cc/Cf/Cs/Co/Cn) is the
2688
- // backstop — it is strictly broader than the family union (also catches
2689
- // U+007F, U+0080-009F, private-use, unassigned), so the family pass is a
2690
- // documented-intent superset removal and the result is identical to the
2691
- // single \p{C} strip.
1917
+ // The named threat families go through the shared vendored tables, so that
1918
+ // vocabulary has one source of truth; \p{C} is the strictly broader backstop.
2692
1919
  const familyStripped = codepointClass.applyCharStripPolicies(normalised, {
2693
1920
  bidiPolicy: 'strip',
2694
1921
  controlPolicy: 'strip',
@@ -2698,79 +1925,33 @@ function sanitizeOperatorText(s) {
2698
1925
  const stripped = familyStripped.replace(/\p{C}/gu, '');
2699
1926
  const trimmed = stripped.trim();
2700
1927
  if (trimmed.length === 0) return null;
2701
- // Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
2702
- // units, so a 256-codepoint astral-plane string isn't silently extended
2703
- // past the cap by surrogate-pair encoding).
1928
+ // 256 CODEPOINTS, counted with Array.from: a `.length` cap measures UTF-16
1929
+ // code units, so astral-plane text would slip a longer string past it.
2704
1930
  const cps = Array.from(trimmed);
2705
1931
  if (cps.length <= 256) return cps.join('');
2706
1932
  return cps.slice(0, 256).join('');
2707
1933
  }
2708
1934
 
2709
1935
  /**
2710
- * Build a single evidence bundle in the requested machine-readable format.
2711
- *
2712
- * Positional contract — the seven phase functions cache the closure over
2713
- * `playbook`, `analyze`, and `validate` so consumers don't reach into the
2714
- * runner's intermediate state. Library callers that bypass close() (e.g.
2715
- * external dashboards re-rendering a stored attestation) MUST honor the
2716
- * same parameter order, names, and types.
2717
- *
2718
- * @param {string} format Output dialect. One of: 'csaf-2.0',
2719
- * 'sarif' / 'sarif-2.1.0', 'openvex' /
2720
- * 'openvex-0.2.0', 'summary', 'markdown'.
2721
- * Unknown values return a stub with
2722
- * supported_formats so callers can branch.
2723
- * @param {object} playbook Playbook record loaded via loadPlaybook().
2724
- * Provides _meta.id / version, domain.name,
2725
- * phases.look.artifacts (for SARIF
2726
- * locations), and feeds_into / mutex.
2727
- * @param {object} analyze Output of analyze(). Carries matched_cves,
2728
- * _detect_indicators, framework_gap_mapping,
2729
- * rwep, blast_radius_score,
2730
- * _detect_classification.
2731
- * @param {object} validate Output of validate(). Carries
2732
- * selected_remediation, remediation_paths,
2733
- * evidence_requirements,
2734
- * residual_risk_statement.
2735
- * @param {object} agentSignals Agent-submitted signals (signal_overrides
2736
- * merged + cleaned). Drives the OpenVEX
2737
- * vex_status:'fixed' attestation trail and
2738
- * the CSAF cvss_v3 score-block gate.
2739
- * @param {string} sessionId Run session id (threaded from run()).
2740
- * Becomes part of CSAF tracking.id,
2741
- * OpenVEX @id, and the on-disk attestation
2742
- * file name so all three correlate.
2743
- * @param {string=} issuedAt Optional ISO 8601 timestamp. Pinning this
2744
- * across multi-format emits keeps CSAF /
2745
- * OpenVEX / SARIF agreed on milliseconds;
2746
- * each call would otherwise crystallise a
2747
- * fresh Date.now().
2748
- * @param {object=} runOpts Operator / library knobs. Recognised
2749
- * fields: operator, publisherNamespace,
2750
- * csafStatus, tlp, _runErrors accumulator.
2751
- * @returns {object} The requested format's document body.
1936
+ * Build one evidence bundle in the requested format ('csaf-2.0', 'sarif',
1937
+ * 'openvex', 'summary', 'markdown', 'json' and their versioned spellings). An
1938
+ * unrecognised format returns a stub carrying supported_formats. `sessionId`
1939
+ * becomes part of the CSAF tracking.id, the OpenVEX @id and the attestation
1940
+ * name, so all three correlate. `issuedAt` pins the timestamp across a
1941
+ * multi-format emit, without which each call crystallises its own Date.now().
1942
+ * `runOpts` is read for operator, publisherNamespace, csafStatus, tlp and the
1943
+ * _runErrors accumulator.
2752
1944
  */
2753
1945
  function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId, issuedAt, runOpts) {
2754
1946
  runOpts = runOpts || {};
2755
1947
  const playbookSlug = urnSlug(playbook._meta.id);
2756
1948
  const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
2757
- // Pin one `now` value per bundle build (and accept an
2758
- // upstream-provided issuedAt) so multi-format emit produces identical
2759
- // tracking timestamps across CSAF / OpenVEX / SARIF when close() is
2760
- // building several formats from the same run. Without the parameter,
2761
- // each invocation crystallises a fresh `Date.now()` and bundle_body
2762
- // versus bundles_by_format[primary] diverge on milliseconds.
2763
1949
  const now = typeof issuedAt === 'string' && issuedAt ? issuedAt : new Date().toISOString();
2764
1950
 
2765
- // CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
2766
- // catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
2767
- // under `exceptd:` namespace), so playbooks without catalogue CVEs still
2768
- // emit a non-empty bundle.
2769
- //
2770
- // v0.12.12 (B5): emit a product_tree so csaf_security_advisory documents
2771
- // pass NVD/ENISA/Red Hat dashboard validation. Every vulnerability
2772
- // entry references the product via product_status so the binding is
2773
- // real, not cosmetic.
1951
+ // CSAF-2.0. vulnerabilities[] covers matched catalogue CVEs AND fired
1952
+ // indicators (as pseudo-CVEs), so a playbook with no catalogue CVEs still
1953
+ // emits a non-empty bundle. Every entry references the product through
1954
+ // product_status, which NVD / ENISA / Red Hat dashboards validate.
2774
1955
  if (format === 'csaf-2.0') {
2775
1956
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
2776
1957
  const fullProductNames = [{
@@ -2778,28 +1959,17 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2778
1959
  name: productName,
2779
1960
  product_identification_helper: { purl: productPurl }
2780
1961
  }];
2781
- // `fixed` product_status MUST reflect operator-supplied VEX
2782
- // disposition (vex_status === 'fixed' — see analyze()), not the
2783
- // catalog's global `live_patch_available` flag. The catalog flag
2784
- // means "vendor publishes a live-patch in the world", not "operator
2785
- // deployed it on this host". Declaring every live-patchable CVE as
2786
- // fixed regardless of operator evidence would produce CSAF documents
2787
- // that lie to downstream NVD / Red Hat dashboards. When
2788
- // live_patch_available is the only signal, status stays
2789
- // known_affected and the live-patch route is surfaced as a
1962
+ // A `fixed` product_status reflects the operator's VEX disposition, never
1963
+ // the catalog's live_patch_available flag: that flag says the vendor
1964
+ // publishes a live-patch, not that this operator deployed it, and treating
1965
+ // it as fixed makes the document lie to downstream dashboards.
1966
+ // Live-patch-only stays known_affected, with the route offered as a
2790
1967
  // `vendor_fix` remediation.
2791
- // CSAF §3.2.1.2 restricts the `cve` field to the CVE-id
2792
- // regex `^CVE-[0-9]{4}-[0-9]{4,}$`. The catalog also keys non-CVE
2793
- // identifiers off `cve_id` (MAL-2026-3083, GHSA-…, OSV-…); strict
2794
- // validators (BSI CSAF validator, ENISA dashboard) refuse documents that
2795
- // place non-CVE values in `cve`. Branch by prefix and route non-CVE ids
2796
- // to the `ids[]` array with a real `system_name`.
2797
1968
  //
2798
- // CSAF §3.2.1.5 requires `cvss_v3.vectorString` when a
2799
- // cvss_v3 score block is emitted. Drop the entire score block when the
2800
- // catalog has no CVSS data (score AND vector both unset); otherwise
2801
- // include version + baseScore + vectorString + baseSeverity from the
2802
- // catalog entry.
1969
+ // CSAF §3.2.1.2 restricts `cve` to `^CVE-[0-9]{4}-[0-9]{4,}$`, and the
1970
+ // catalog also keys MAL-/GHSA-/OSV- identifiers off cve_id, so those route
1971
+ // to `ids[]`. §3.2.1.5 requires a vectorString whenever a cvss_v3 block is
1972
+ // emitted, so the block is dropped when there is neither score nor vector.
2803
1973
  const csafCvssSeverity = (score) => {
2804
1974
  if (typeof score !== 'number') return null;
2805
1975
  if (score >= 9.0) return 'CRITICAL';
@@ -2812,39 +1982,27 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2812
1982
  if (typeof vec !== 'string') return '3.1';
2813
1983
  const m = vec.match(/^CVSS:(\d+\.\d+)\//);
2814
1984
  if (!m) return '3.1';
2815
- // Returns the declared version verbatim. The CALLER is responsible for
2816
- // gating cvss_v3 emission to 3.0 / 3.1 per CSAF 2.0 schema. 2.0 and
2817
- // 4.0 vectors are tagged here for diagnostic clarity but never reach
2818
- // the cvss_v3 block downstream.
1985
+ // The declared version, verbatim — gating emission to 3.0 / 3.1 per the
1986
+ // CSAF 2.0 schema is the CALLER's job.
2819
1987
  return m[1];
2820
1988
  };
2821
1989
  const csafIdsFor = (id) => {
2822
- // null / undefined / non-string id MUST NOT emit literal "null" /
2823
- // "undefined" text into the vulnerabilities[] entry. String(id)
2824
- // would coerce both to those literals; strict validators then
2825
- // reject the document and operators see a phantom "null" CVE in
2826
- // dashboards. Return null so the caller skips the entry entirely
2827
- // and surfaces a runtime_error for the missing id.
1990
+ // Null for a missing id, so the caller drops the entry: String(id) puts
1991
+ // literal "null" text into vulnerabilities[], which validators reject.
2828
1992
  if (typeof id !== 'string' || !id) return null;
2829
1993
  if (id.startsWith('GHSA-')) return { system_name: 'GHSA', text: id };
2830
1994
  if (id.startsWith('MAL-')) return { system_name: 'Malicious-Package', text: id };
2831
1995
  if (id.startsWith('OSV-')) return { system_name: 'OSV', text: id };
2832
1996
  if (id.startsWith('SNYK-')) return { system_name: 'Snyk', text: id };
2833
- // RUSTSEC advisories carry their own tracking authority
2834
- // (https://rustsec.org); mis-routing them to system_name 'OSV'
2835
- // loses the upstream provenance link and confuses downstream
2836
- // ingesters that resolve by (system_name, text) pair.
1997
+ // RUSTSEC is its own tracking authority; routing it to 'OSV' breaks the
1998
+ // (system_name, text) pair downstream ingesters resolve by.
2837
1999
  if (id.startsWith('RUSTSEC-')) return { system_name: 'RUSTSEC', text: id };
2838
- // Genuinely-unknown prefix surfaces as `exceptd-unknown` so
2839
- // downstream ingesters see that the authority wasn't recognised
2840
- // rather than misattributing every unknown id to OSV.
2000
+ // An unrecognised authority says so, rather than being misattributed.
2841
2001
  return { system_name: 'exceptd-unknown', text: id };
2842
2002
  };
2843
2003
  const CSAF_CVE_RE = /^CVE-\d{4}-\d{4,}$/;
2844
2004
 
2845
- // Build the product-tree branches up front so each CVE's per-version CSAFPID
2846
- // leaves can be bound into its product_status.known_affected (and the same
2847
- // tree reused for product_tree below — buildCsafBranches is not re-run).
2005
+ // Built up front so the CSAFPID leaves bind into product_status below.
2848
2006
  const csafProductTree = buildCsafBranches(analyze.matched_cves || [], runOpts);
2849
2007
 
2850
2008
  const cveVulns = analyze.matched_cves.map(c => {
@@ -2855,11 +2013,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2855
2013
  || (c.live_patch_available ? 'Vendor publishes a live-patch — see CVE catalog `live_patch_tools` for the operator-side step.' : 'See selected remediation path.'),
2856
2014
  product_ids: [productId],
2857
2015
  }];
2858
- // Catalog entries with a missing / non-string cve_id would
2859
- // otherwise produce literal `text: "null"` / `text: "undefined"`
2860
- // entries under ids[]. Skip the vulnerability entry entirely and
2861
- // surface a runtime_error so the catalog gap is visible to
2862
- // operators / CI gates.
2863
2016
  const idIsCve = typeof c.cve_id === 'string' && CSAF_CVE_RE.test(c.cve_id);
2864
2017
  let idEntry = null;
2865
2018
  if (!idIsCve) {
@@ -2875,28 +2028,12 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2875
2028
  return null;
2876
2029
  }
2877
2030
  }
2878
- // only emit cvss_v3 score block when we have a real
2879
- // vector string AND a numeric score. Pre-fix every vuln carried
2880
- // `cvss_v3: { base_score: 0 }` even when the catalog had no CVSS
2881
- // signal — strict validators reject the truncated block, and
2882
- // `base_score: 0` was a downstream-misleading default that suggested
2883
- // an authoritative "informational" score where there was simply no
2884
- // data.
2885
- //
2886
- // CSAF 2.0 `cvss_v3` ONLY accepts version 3.0 / 3.1. Catalog
2887
- // vectors prefixed CVSS:2.0/ or CVSS:4.0/ would otherwise emit a
2888
- // cvss_v3 block with version: '2.0' / '4.0', which strict
2889
- // validators (BSI CSAF Validator) reject outright. Drop the block
2890
- // for non-3.x vectors and surface a runtime_error so operators can
2891
- // see why their CVSS data didn't make it through.
2031
+ // A real vector AND a numeric score: an emitted `base_score: 0` reads as
2032
+ // an authoritative "informational" score where there is simply no data.
2892
2033
  const hasCvss = typeof c.cvss_score === 'number' && typeof c.cvss_vector === 'string' && c.cvss_vector.length > 0;
2893
- // Strict CVSS 3.1 parse (lib/scoring.parseCvss31Vector). The pre-fix
2894
- // permissive regex accepted any CVSS:X.Y/... prefix and would emit a
2895
- // cvss_v3 block keyed off a malformed vector — strict validators
2896
- // (BSI CSAF Validator, ENISA dashboard) then reject the whole
2897
- // document. Strict parse failures surface as a `csaf_cvss_invalid`
2898
- // runtime_error, the cvss_v3 block is omitted, and the rest of the
2899
- // vulnerability entry (product_status, remediations, etc.) survives.
2034
+ // cvss_v3 accepts only 3.0 / 3.1, and validators reject a block keyed off
2035
+ // a 2.0 / 4.0 or malformed vector. A strict-parse failure raises
2036
+ // csaf_cvss_invalid and omits the block alone — the entry survives.
2900
2037
  let strictParse = null;
2901
2038
  if (hasCvss) {
2902
2039
  strictParse = scoring.parseCvss31Vector(c.cvss_vector);
@@ -2920,10 +2057,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2920
2057
  }
2921
2058
  }] : [];
2922
2059
  const base = {
2923
- // CSAF Profile 4 (security_advisory) mandatory test 6.1.27.5 requires
2924
- // every /vulnerabilities[] item to carry `notes`. Without this the
2925
- // CVE-keyed entries failed strict validation (the indicator pseudo-CVE
2926
- // entries already carried notes; real-CVE entries did not).
2060
+ // CSAF Profile 4 mandatory test 6.1.27.5: every /vulnerabilities[]
2061
+ // item carries `notes`.
2927
2062
  notes: [{
2928
2063
  category: 'description',
2929
2064
  text: `${c.cve_id}: RWEP ${c.rwep}${c.active_exploitation ? `, active_exploitation=${c.active_exploitation}` : ''}${c.cisa_kev ? ', CISA KEV' : ''}.`,
@@ -2931,44 +2066,30 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2931
2066
  scores,
2932
2067
  threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: `Active exploitation confirmed${c.cisa_kev ? ' (CISA KEV)' : ''}.` }] : [],
2933
2068
  remediations,
2934
- // Bind this CVE's per-version CSAFPID leaves (from the product tree) into
2935
- // known_affected, so a CSAF consumer correlating product_status back to
2936
- // the branches tree resolves the real affected versions, not only the
2937
- // opaque synthetic product. The leaves are parsed from affected_products
2938
- // / affected_versions — VULNERABLE version ranges — so they belong ONLY
2939
- // under known_affected. The VEX-fixed disposition keeps just the synthetic
2940
- // target product under `fixed`; adding affected ranges there would
2941
- // mislabel vulnerable versions as fixed releases.
2069
+ // The CSAFPID leaves are VULNERABLE ranges, so they belong only under
2070
+ // known_affected. `fixed` keeps the synthetic product alone — affected
2071
+ // ranges there would read as fixed releases.
2942
2072
  product_status: isFixed
2943
2073
  ? { fixed: [productId] }
2944
2074
  : { known_affected: [productId, ...(csafProductTree.cveProductIds[c.cve_id] || [])] }
2945
2075
  };
2946
- // route by id shape.
2947
2076
  if (idIsCve) {
2948
2077
  return { cve: c.cve_id, ...base };
2949
2078
  }
2950
2079
  return { ids: [idEntry], ...base };
2951
2080
  }).filter(v => v != null);
2952
2081
  const indicatorVulns = indicatorHits.map(i => ({
2953
- // CSAF `system_name` values land in operator-facing validators; the
2954
- // "exceptd-indicator" pseudo-authority is namespaced enough that NVD /
2955
- // Red Hat / ENISA dashboards render it as a non-CVE finding without
2956
- // misattributing to a real registry (CVE, GHSA, OSV).
2082
+ // The 'exceptd-indicator' pseudo-authority is namespaced so NVD / Red Hat
2083
+ // / ENISA dashboards render a non-CVE finding without misattributing it
2084
+ // to a real registry.
2957
2085
  ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
2958
2086
  notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
2959
2087
  remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
2960
2088
  product_status: { known_affected: [productId] }
2961
2089
  }));
2962
- // Framework-gap entries land in `document.notes[]` with
2963
- // `category: details` rather than `vulnerabilities[]` with
2964
- // `ids: [{ system_name: 'exceptd-framework-gap' }]`. The `system_name`
2965
- // slot is reserved for recognised vulnerability tracking authorities
2966
- // (CVE, GHSA, etc.); exceptd-framework-gap is not one, and every
2967
- // downstream CSAF consumer (NVD ingester, Red Hat dashboard, ENISA
2968
- // validator) would flag the run for unknown ids and render
2969
- // false-positive advisories at the framework_gap_mapping length.
2970
- // Notes are the right home for advisory context that is not itself
2971
- // a pseudo-CVE.
2090
+ // Framework gaps belong in document.notes[], not vulnerabilities[]: the
2091
+ // system_name slot is for recognised tracking authorities, and a made-up
2092
+ // one renders a false-positive advisory per gap downstream.
2972
2093
  const gapNotes = (analyze.framework_gap_mapping || []).map((g, idx) => {
2973
2094
  const lines = [
2974
2095
  `Framework: ${g.framework}`,
@@ -2982,14 +2103,10 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
2982
2103
  text: lines.join('\n'),
2983
2104
  };
2984
2105
  });
2985
- // CSAF §3.1.7.4 publisher.namespace MUST be the trust
2986
- // anchor of the entity publishing the advisory — the OPERATOR running the
2987
- // scan, not the tool vendor. Pre-fix every CSAF emitted by the runner
2988
- // claimed https://exceptd.com as namespace, falsely attributing
2989
- // responsibility for advisory accuracy to the tooling provider. Resolve
2990
- // in priority order: explicit --publisher-namespace > --operator if it
2991
- // looks URL-shaped > fallback `urn:exceptd:operator:unknown` with a note
2992
- // documenting the gap.
2106
+ // CSAF §3.1.7.4: publisher.namespace is the trust anchor of the entity
2107
+ // publishing the advisory — the OPERATOR running the scan, not the tool
2108
+ // vendor, who is not answerable for its accuracy. Resolved as
2109
+ // --publisher-namespace, then a URL-shaped --operator, then a marked fallback.
2993
2110
  const operatorClean = sanitizeOperatorText(runOpts.operator);
2994
2111
  const explicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
2995
2112
  let publisherNamespace;
@@ -3009,14 +2126,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3009
2126
  title: 'Publisher namespace not supplied',
3010
2127
  text: 'No --publisher-namespace and no URL-shaped --operator were supplied to this run. CSAF §3.1.7.4 requires the namespace to be the publisher\'s trust anchor — i.e. the OPERATOR running the scan, not the tooling vendor. Re-emit with `--publisher-namespace https://your-org.example` (or a URL-shaped `--operator`) to attribute responsibility for advisory accuracy correctly.'
3011
2128
  }] : [];
3012
- // ALSO surface the unclaimed-publisher condition through
3013
- // the structured runtime_errors[] accumulator so machine-readable
3014
- // consumers (CI gates, dashboards) can branch on it without parsing
3015
- // notes[] prose. The orchestrator's post-close pass folds late-pushed
3016
- // _runErrors into phases.analyze.runtime_errors before the run-level
3017
- // return, so the warning surfaces alongside other run-time anomalies.
3018
- // De-dupe: only push once per bundle-build pass (multi-format emit
3019
- // builds CSAF once via memoization, so this fires at most once per run).
2129
+ // Also on runtime_errors[], so a CI gate can branch on the unclaimed
2130
+ // publisher without parsing notes[] prose.
3020
2131
  if (publisherNamespaceSource === 'fallback' && Array.isArray(runOpts._runErrors)) {
3021
2132
  pushRunError(runOpts._runErrors, {
3022
2133
  kind: 'bundle_publisher_unclaimed',
@@ -3025,11 +2136,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3025
2136
  }, { dedupeKey: () => 'singleton' });
3026
2137
  }
3027
2138
 
3028
- // thread the validated --operator name into
3029
- // tracking.generator (engine identity) AND publisher.contact_details
3030
- // (operator-of-record). engine.version is read from the package once per
3031
- // process. contact_details is omitted when no operator was supplied so
3032
- // the field doesn't carry a misleading null.
2139
+ // contact_details is the operator-of-record, omitted entirely when no
2140
+ // operator was supplied rather than carrying a misleading null.
3033
2141
  const publisherBlock = {
3034
2142
  category: 'vendor',
3035
2143
  name: 'exceptd',
@@ -3037,42 +2145,29 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3037
2145
  };
3038
2146
  if (operatorClean) publisherBlock.contact_details = operatorClean;
3039
2147
 
3040
- // CSAF §3.1.11.3.5.1 defines `final` as an immutable
3041
- // advisory; subsequent re-emits against the same tracking.id are
3042
- // refused by strict validators (BSI CSAF Validator). Runtime detection
3043
- // runs with no operator review loop are inherently revisable, so the
3044
- // default is `interim`. Operators who have reviewed and are ready to
3045
- // promote pass `--csaf-status final` (threaded via runOpts.csafStatus);
3046
- // any other value falls back to `interim` rather than emitting an
3047
- // unrecognized status word.
2148
+ // CSAF §3.1.11.3.5.1 makes `final` immutable — validators refuse a re-emit
2149
+ // against the same tracking.id — and a detection run with no operator
2150
+ // review loop is revisable, so the default is `interim`. --csaf-status
2151
+ // promotes it; anything unrecognised falls back rather than being emitted.
3048
2152
  const allowedCsafStatuses = new Set(['draft', 'interim', 'final']);
3049
2153
  const csafStatus = allowedCsafStatuses.has(runOpts.csafStatus)
3050
2154
  ? runOpts.csafStatus
3051
2155
  : 'interim';
3052
2156
 
3053
- // CSAF §3.1.4 `distribution.tlp`. Optional. When the operator supplies
3054
- // `--tlp <label>` (threaded as runOpts.tlp), emit
3055
- // distribution.tlp.label + distribution.text. CSAF allows omission of
3056
- // the whole distribution block when no level is declared; the
3057
- // pre-fix runner had no surface for this at all.
2157
+ // CSAF §3.1.4 distribution.tlp is optional, and the whole block is omitted
2158
+ // when --tlp declares no level.
3058
2159
  const allowedTlp = new Set(['CLEAR', 'GREEN', 'AMBER', 'AMBER+STRICT', 'RED']);
3059
- // CSAF 2.0 §3.2.1.5.2 pins tlp.label to the TLP 1.0 enum
3060
- // (WHITE/GREEN/AMBER/RED). Map the modern TLP 2.0 labels the CLI accepts
3061
- // onto that enum so the emitted document stays schema-valid for strict
3062
- // CSAF 2.0 consumers, while preserving the operator's exact label in the
3063
- // free-form `text` field. (CLEAR≡WHITE; AMBER+STRICT carries AMBER's
3064
- // disclosure scope plus a stricter handling note.)
2160
+ // CSAF 2.0 §3.2.1.5.2 pins tlp.label to the TLP 1.0 enum, so the TLP 2.0
2161
+ // labels the CLI accepts map onto it and the operator's exact label
2162
+ // survives in the free-form `text`.
3065
2163
  const CSAF_TLP_LABEL = { CLEAR: 'WHITE', GREEN: 'GREEN', AMBER: 'AMBER', 'AMBER+STRICT': 'AMBER', RED: 'RED' };
3066
2164
  const csafDistribution = (runOpts.tlp && allowedTlp.has(runOpts.tlp))
3067
2165
  ? { tlp: { label: CSAF_TLP_LABEL[runOpts.tlp] }, text: `TLP:${runOpts.tlp}` }
3068
2166
  : null;
3069
2167
 
3070
- // CSAF 2.0: an advisory with zero vulnerabilities is a csaf_informational_advisory
3071
- // (Profile 5, which does not require /vulnerabilities) rather than a
3072
- // csaf_security_advisory (Profile 4, where an empty vulnerabilities array is
3073
- // semantically wrong and warns under strict profile validators). A clean run
3074
- // becomes an informational attestation; any firing CVE/indicator keeps the
3075
- // security-advisory category.
2168
+ // A zero-vulnerability advisory is informational (the profile that does not
2169
+ // require /vulnerabilities), not a security advisory with an empty array —
2170
+ // which is semantically wrong and warns under strict profile validators.
3076
2171
  const csafCategory = (cveVulns.length + indicatorVulns.length) > 0
3077
2172
  ? 'csaf_security_advisory'
3078
2173
  : 'csaf_informational_advisory';
@@ -3083,59 +2178,49 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3083
2178
  publisher: publisherBlock,
3084
2179
  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))`,
3085
2180
  notes: [...namespaceFallbackNote, ...gapNotes],
3086
- // Profile 3 (csaf_informational_advisory) mandatory test 6.1.27.2
3087
- // requires /document/references with at least one `external` item. A
3088
- // security_advisory carries its references inside the vulnerabilities,
3089
- // so add this only for the informational (clean-run) profile.
2181
+ // Mandatory test 6.1.27.2 requires /document/references with at least
2182
+ // one `external` item; a security_advisory carries its references
2183
+ // inside the vulnerabilities instead.
3090
2184
  ...(csafCategory === 'csaf_informational_advisory' ? {
3091
2185
  references: [{ category: 'external', summary: `exceptd playbook: ${playbook._meta.id}`, url: `https://exceptd.com/playbooks/${playbook._meta.id}` }],
3092
2186
  } : {}),
3093
2187
  ...(csafDistribution ? { distribution: csafDistribution } : {}),
3094
2188
  tracking: {
3095
- // F2/F9: CSAF tracking.id binds to the run's session_id (threaded
3096
- // from run() via close()) so attestation file names, OpenVEX
3097
- // @id, and CSAF tracking.id all share the same correlation
3098
- // identifier. Pre-fix the timestamp was used, so two runs in
3099
- // the same millisecond collided and one run's documents
3100
- // referenced ids that didn't match anything else on disk.
2189
+ // Keyed on session_id, not a timestamp: two runs in the same
2190
+ // millisecond would collide, and the id must match the OpenVEX @id
2191
+ // and the attestation file name on disk.
3101
2192
  id: `exceptd-${playbook._meta.id}-${sessionId}`,
3102
2193
  status: csafStatus,
3103
2194
  version: playbook._meta.version,
3104
- // name the engine that emitted the advisory.
3105
- // CSAF §3.1.11.3.2 places this under tracking.generator.engine.
2195
+ // CSAF §3.1.11.3.2 places the emitting engine here.
3106
2196
  generator: {
3107
2197
  engine: { name: 'exceptd', version: getEngineVersion() },
3108
2198
  date: now,
3109
2199
  },
3110
2200
  initial_release_date: now,
3111
2201
  current_release_date: now,
3112
- // CSAF 6.1.30 requires homogeneous versioning (all version_t use the
3113
- // same scheme) and 6.1.16 requires tracking.version === the last
3114
- // revision_history number. tracking.version is the playbook semver,
3115
- // so the single revision entry must carry that same semver (not the
3116
- // integer "1", which mixed schemes AND mismatched the version).
2202
+ // CSAF 6.1.30 requires one versioning scheme throughout and 6.1.16
2203
+ // requires tracking.version to equal the last revision_history
2204
+ // number. tracking.version is the playbook semver, so this entry
2205
+ // carries the same semver — an integer "1" would break both.
3117
2206
  revision_history: [{ number: playbook._meta.version, date: now, summary: 'Initial finding emission' }]
3118
2207
  }
3119
2208
  },
3120
- // CSAF Profile 3 (informational_advisory) forbids /vulnerabilities
3121
- // (mandatory test 6.1.27.3) and an informational advisory carrying a
3122
- // /product_tree is misleading (§4.3 — a reader must assume every named
3123
- // product is affected). Emit both ONLY for the security_advisory profile;
3124
- // a clean run's informational advisory carries neither.
2209
+ // The informational profile forbids /vulnerabilities (test 6.1.27.3),
2210
+ // and a /product_tree there is misleading — §4.3 has the reader assume
2211
+ // every named product is affected. Both are security-advisory only.
3125
2212
  ...(csafCategory === 'csaf_security_advisory' ? {
3126
2213
  product_tree: (function () {
3127
- // Synthesize a 3-level branches tree (vendor → product → version)
3128
- // from catalog data. CSAF §3.1.5.1 makes branches[] strongly
3129
- // recommended because NVD / ENISA / Red Hat dashboards render the
3130
- // affected-product list off the branches tree, not full_product_names[].
2214
+ // NVD / ENISA / Red Hat dashboards render the affected-product list
2215
+ // off branches[], not full_product_names[] (CSAF §3.1.5.1).
3131
2216
  const branches = csafProductTree.branches;
3132
2217
  const tree = { full_product_names: fullProductNames };
3133
2218
  if (branches.length > 0) tree.branches = branches;
3134
2219
  return tree;
3135
2220
  })(),
3136
2221
  vulnerabilities: (function () {
3137
- // v0.12.27: deterministic mode sorts vulnerabilities[] by their
3138
- // primary identifier ascending; default mode preserves insertion order.
2222
+ // Deterministic mode sorts by primary identifier; otherwise
2223
+ // insertion order stands.
3139
2224
  const all = [...cveVulns, ...indicatorVulns];
3140
2225
  if (runOpts && runOpts.bundleDeterministic === true) {
3141
2226
  const keyOf = (v) => (typeof v.cve === 'string' && v.cve)
@@ -3159,30 +2244,14 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3159
2244
  };
3160
2245
  }
3161
2246
 
3162
- // SARIF 2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
3163
- // / most static-analysis tooling.
3164
- //
3165
- // v0.12.12 (B6): thread artifact source paths through to
3166
- // result.locations[].physicalLocation.artifactLocation.uri. GitHub Code
3167
- // Scanning hides results without populated locations, so the heuristic
3168
- // ensures clean playbook runs still surface findings in the alerts UI.
3169
- // v0.12.12 (B7): omit null property-bag keys so SARIF viewers don't
3170
- // render empty fields.
3171
2247
  if (format === 'sarif' || format === 'sarif-2.1.0') {
2248
+ // Null property-bag keys render as empty rows in SARIF viewers.
3172
2249
  const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
3173
- // SARIF rule ids are global within a single sarif-log run.
3174
- // Pre-fix, generic ruleIds like `framework-gap-0` (and shared CVE ids
3175
- // across playbooks) collided when results from multiple playbook runs
3176
- // were merged into one SARIF document — GitHub Code Scanning de-dupes
3177
- // by ruleId, so the second playbook's rule definition silently
3178
- // overwrote the first. Prefix every ruleId with the playbook slug so
3179
- // every rule definition is unambiguously attributable to one playbook,
3180
- // and cross-playbook merges retain all results.
2250
+ // Rule ids are global within a sarif-log run and GitHub Code Scanning
2251
+ // de-dupes by ruleId, so a generic id (`framework-gap-0`, a shared CVE id)
2252
+ // would let one playbook's rule silently overwrite another's on merge.
3181
2253
  const rulePrefix = `${playbookSlug}/`;
3182
- // CVE-match results get the coarse playbook-source location fallback
3183
- // (passing a null indicator skips the per-indicator evidence-locations
3184
- // branch). Without any `locations`, GitHub Code Scanning silently DROPS
3185
- // these results — the highest-severity result class would never surface.
2254
+ // A null indicator takes the coarse playbook-source location fallback.
3186
2255
  const cveResults = analyze.matched_cves.map(c => {
3187
2256
  const result = {
3188
2257
  ruleId: `${rulePrefix}${c.cve_id}`,
@@ -3198,9 +2267,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3198
2267
  blast_radius_score: analyze.blast_radius_score,
3199
2268
  }),
3200
2269
  };
3201
- // Always carry a location (physical when known, else a rule-scoped
3202
- // logicalLocations fallback) so GitHub Code Scanning does not drop the
3203
- // highest-severity result class.
2270
+ // Always a location — physical when known, else the rule-scoped logical
2271
+ // fallback — or Code Scanning drops the highest-severity result class.
3204
2272
  result.locations = sarifResultLocations(playbook, null, result.ruleId);
3205
2273
  return result;
3206
2274
  });
@@ -3224,32 +2292,27 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3224
2292
  });
3225
2293
  const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
3226
2294
  ruleId: `${rulePrefix}framework-gap-${idx}`,
3227
- // Framework gaps are control-design observations, not vulnerabilities —
3228
- // SARIF §3.27.9 `kind: informational` routes them appropriately. That
3229
- // same clause requires: when kind !== 'fail', a present `level` SHALL be
3230
- // 'none'. Pairing 'informational' with 'note' is a schema violation that
3231
- // strict validators / GitHub code-scanning reject — use 'none'.
2295
+ // Framework gaps are control-design observations, not vulnerabilities.
2296
+ // SARIF §3.27.9 requires a present `level` to be 'none' whenever kind is
2297
+ // not 'fail' — pairing 'informational' with 'note' is a schema violation
2298
+ // strict validators and Code Scanning reject.
3232
2299
  kind: 'informational',
3233
2300
  level: 'none',
3234
2301
  message: { text: `${g.framework}: ${g.claimed_control} — ${g.actual_gap}${g.required_control ? '. Required: ' + g.required_control : ''}` },
3235
2302
  properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
3236
2303
  }));
3237
2304
  const cveRules = analyze.matched_cves.map(c => {
3238
- // Resolve the issuing authority by id shape rather than hardcoding NVD.
3239
- // A non-CVE matched id (MAL-/GHSA-/OSV-/RUSTSEC-/SNYK-) must NOT carry an
3240
- // nvd.nist.gov URL — that link 404s and presents the id as an NVD CVE.
3241
2305
  const authority = advisoryAuthorityFor(c.cve_id);
3242
2306
  const isCve = CVE_ID_RE.test(typeof c.cve_id === 'string' ? c.cve_id : '');
3243
2307
  const rule = {
3244
2308
  id: `${rulePrefix}${c.cve_id}`,
3245
- // For a non-CVE id, qualify the short description with its authority so
3246
- // a SARIF viewer doesn't read e.g. a MAL- id as an NVD CVE.
2309
+ // A non-CVE id is qualified by its authority so a viewer does not read
2310
+ // a MAL- id as an NVD CVE.
3247
2311
  shortDescription: { text: isCve ? c.cve_id : `${c.cve_id} (${authority.system_name || 'non-CVE advisory'})` },
3248
2312
  fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation}` },
3249
2313
  defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
3250
2314
  };
3251
- // helpUri is optional in SARIF 2.1.0; omit it entirely when the authority
3252
- // has no canonical per-id advisory page rather than emit a broken link.
2315
+ // helpUri is optional; omitting it beats emitting a link that 404s.
3253
2316
  if (authority.helpUri) rule.helpUri = authority.helpUri;
3254
2317
  return rule;
3255
2318
  });
@@ -3275,11 +2338,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3275
2338
  } },
3276
2339
  results: [...cveResults, ...indicatorResults, ...gapResults],
3277
2340
  invocations: [{ executionSuccessful: (analyze._detect_classification !== 'inconclusive'), properties: stripNulls({
3278
- // Apply the stripNulls contract here too — the `remediation`
3279
- // field is null for any run that didn't surface a
3280
- // selected_remediation, and SARIF viewers render null property
3281
- // values as visible empty rows. Same helper as the result
3282
- // property bags above.
3283
2341
  playbook: playbook._meta.id, classification: analyze._detect_classification || 'unknown',
3284
2342
  rwep_adjusted: analyze.rwep?.adjusted || 0,
3285
2343
  remediation: validate.selected_remediation?.id || null,
@@ -3288,27 +2346,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3288
2346
  };
3289
2347
  }
3290
2348
 
3291
- // OpenVEX 0.2.0 — supply-chain VEX statements.
3292
- //
3293
- // v0.12.12 (B1-B4): correctness sweep against the OpenVEX 0.2.0 spec.
3294
- // - B1: every statement now carries a `products` array (spec MUST).
3295
- // - B2: `status` derives from the verdict + confidence rather than being
3296
- // hard-coded to `under_investigation`. Hits emit `affected` with
3297
- // an action_statement; misses emit `not_affected` with a
3298
- // justification; inconclusive findings keep `under_investigation`.
3299
- // - B3: framework gaps are control-design observations, not
3300
- // vulnerabilities — they are removed from the VEX emit path. They
3301
- // remain in CSAF (informational notes) and SARIF (kind:
3302
- // informational rules).
3303
- // - B4: vulnerability `@id` values switch to the registered URN namespace
3304
- // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
3305
- // they pass IRI validation in downstream VEX consumers.
2349
+ // OpenVEX 0.2.0. Every statement carries a `products` array (spec MUST) and a
2350
+ // status from the verdict: a hit is `affected` with an action_statement, a
2351
+ // miss `not_affected` with a justification, anything else
2352
+ // `under_investigation`. Framework gaps never enter this path.
3306
2353
  if (format === 'openvex' || format === 'openvex-0.2.0') {
3307
- // Reuse the bundle-wide `now` so OpenVEX `timestamp` aligns with
3308
- // CSAF `document.tracking.initial_release_date` when both formats are
3309
- // emitted in the same close() pass. A per-format Date.now() would
3310
- // cause the two bundles in bundles_by_format to disagree on
3311
- // milliseconds.
3312
2354
  const issued = now;
3313
2355
  const productEntry = {
3314
2356
  '@id': productPurl,
@@ -3324,17 +2366,9 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3324
2366
  if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
3325
2367
  return fallback;
3326
2368
  };
3327
- // Same `vex_status === 'fixed'` correctness rule as the CSAF
3328
- // emitter. The catalog `live_patch_available` flag is a global
3329
- // "vendor publishes a live-patch" signal, not an operator-host
3330
- // disposition. Treating it as `status: fixed` would make OpenVEX
3331
- // statements claim resolution the operator hadn't attested to. VEX
3332
- // consumers downstream of CISA / SBOM / supply-chain pipelines treat
3333
- // `fixed` as authoritative — emitting it without operator attestation
3334
- // is a downstream-misleading bug. The OpenVEX statement says
3335
- // `affected` (with action_statement pointing to the remediation,
3336
- // which may itself be the vendor live-patch route) unless the
3337
- // operator declared `vex_status: fixed` on the matched CVE.
2369
+ // As in the CSAF emitter, only an operator-declared vex_status:'fixed'
2370
+ // yields `fixed`: supply-chain consumers treat it as authoritative, so the
2371
+ // catalog's live_patch_available flag must not claim an unattested fix.
3338
2372
  const cveStatements = analyze.matched_cves.map(c => {
3339
2373
  const stmt = {
3340
2374
  vulnerability: { '@id': vulnIdToUrn(c.cve_id), name: c.cve_id },
@@ -3344,13 +2378,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3344
2378
  };
3345
2379
  if (c.vex_status === 'fixed') {
3346
2380
  stmt.status = 'fixed';
3347
- // OpenVEX 0.2.0 §4.1: `fixed` is an operator-attested resolution,
3348
- // not a global vendor flag. Augment the impact_statement with an
3349
- // evidence trail so downstream supply-chain consumers can chase
3350
- // the attestation back to the operator's submitted evidence.
3351
- // Short-hash is deterministic for the same (cve_id, signals)
3352
- // input — re-emitting the bundle for the same submission yields
3353
- // the same trail.
2381
+ // A trail back to the operator's submission, hashed deterministically
2382
+ // over (cve_id, signals) so a re-emit yields the same one.
3354
2383
  const trailSrc = canonicalStringify({
3355
2384
  cve_id: c.cve_id,
3356
2385
  vex_status: 'fixed',
@@ -3379,10 +2408,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3379
2408
  impact_statement: `Indicator ${i.id} (${i.verdict}; ${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
3380
2409
  };
3381
2410
  if (i.verdict === 'hit') {
3382
- // Deterministic and high-confidence hits both map to `affected`.
3383
- // The `deterministic` flag describes regex specificity, not
3384
- // operator-evidence confidence — neither warrants
3385
- // under_investigation when the indicator actually fired.
2411
+ // `deterministic` describes regex specificity, not evidence
2412
+ // confidence, so a fired indicator is `affected` either way.
3386
2413
  stmt.status = 'affected';
3387
2414
  stmt.action_statement = actionStatementFor(`Run \`exceptd brief ${playbook._meta.id}\` for context.`);
3388
2415
  } else if (i.verdict === 'miss') {
@@ -3393,15 +2420,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3393
2420
  }
3394
2421
  return stmt;
3395
2422
  });
3396
- // OpenVEX `author` identifies the entity attesting to the
3397
- // disposition — for an operator-run scan that is the operator, not the
3398
- // tool vendor. Mirror the CSAF publisher.namespace fallback ladder so a
3399
- // downstream supply-chain consumer keying on `author` resolves to the
3400
- // operator URN. Pre-fix every OpenVEX document falsely attributed
3401
- // dispositions to the tooling provider. Falls back to
3402
- // urn:exceptd:operator:unknown + bundle_publisher_unclaimed runtime
3403
- // warning if neither runOpts.operator nor runOpts.publisherNamespace
3404
- // is supplied.
2423
+ // `author` is the entity attesting to the disposition — the operator, not
2424
+ // the tool vendor. Same fallback ladder as the CSAF publisher.namespace.
3405
2425
  const vexOperatorClean = sanitizeOperatorText(runOpts.operator);
3406
2426
  const vexExplicitNs = sanitizeOperatorText(runOpts.publisherNamespace);
3407
2427
  let vexAuthor;
@@ -3411,9 +2431,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3411
2431
  vexAuthor = vexOperatorClean;
3412
2432
  } else {
3413
2433
  vexAuthor = 'urn:exceptd:operator:unknown';
3414
- // Same shape + singleton dedupe as the CSAF path so a multi-format emit
3415
- // produces one canonical bundle_publisher_unclaimed entry that machine
3416
- // consumers can read consistently (reason/remediation, not message).
2434
+ // Same shape and singleton dedupe as the CSAF path, so a multi-format
2435
+ // emit produces one canonical bundle_publisher_unclaimed entry.
3417
2436
  pushRunError(runOpts._runErrors, {
3418
2437
  kind: 'bundle_publisher_unclaimed',
3419
2438
  reason: 'OpenVEX author fell back to urn:exceptd:operator:unknown because no --publisher-namespace and no URL-shaped --operator were supplied. Disposition attribution is unclaimed on this VEX document.',
@@ -3422,17 +2441,15 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3422
2441
  }
3423
2442
  return {
3424
2443
  '@context': 'https://openvex.dev/ns/v0.2.0',
3425
- // F2/F9: OpenVEX @id baked from session_id (not Date.now()) so the
3426
- // document URN aligns with CSAF tracking.id and on-disk
3427
- // attestation file name. Falls back to a urnSlug if sessionId
3428
- // somehow arrived empty.
2444
+ // Baked from session_id, not Date.now(), so the document URN aligns with
2445
+ // the CSAF tracking.id and the on-disk attestation file name.
3429
2446
  '@id': `https://exceptd.com/vex/${playbookSlug}/${urnSlug(sessionId || 'session')}`,
3430
2447
  author: vexAuthor,
3431
2448
  timestamp: issued,
3432
2449
  version: 1,
3433
2450
  statements: (function () {
3434
- // v0.12.27: deterministic mode sorts statements[] by
3435
- // vulnerability['@id'] ascending. Insertion order otherwise.
2451
+ // Deterministic mode sorts by vulnerability['@id']; otherwise
2452
+ // insertion order stands.
3436
2453
  const all = [...cveStatements, ...indicatorStatements];
3437
2454
  if (runOpts && runOpts.bundleDeterministic === true) {
3438
2455
  const keyOf = (s) => (s && s.vulnerability && typeof s.vulnerability['@id'] === 'string')
@@ -3444,9 +2461,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3444
2461
  };
3445
2462
  }
3446
2463
 
3447
- // v0.11.0 redesign #39: --format summary emits a 5-line digest for CI gates
3448
- // and human triage. Drops everything except verdict + RWEP + blast +
3449
- // feeds_into + jurisdiction clock count.
3450
2464
  if (format === 'summary') {
3451
2465
  return {
3452
2466
  format: 'summary',
@@ -3480,14 +2494,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3480
2494
  return { format: 'markdown', body: lines.join('\n') };
3481
2495
  }
3482
2496
 
3483
- // Native structured-JSON bundle — a superset of `summary` carrying the full
3484
- // finding record. This is the declared bundle_format for the secrets /
3485
- // cred-stores / runtime / citation-hygiene playbooks; without an explicit
3486
- // branch their default bundle fell through to the Unknown-format placeholder
3487
- // below. Reuses the pinned `now` so multi-format builds stay
3488
- // timestamp-consistent, and exposes the same finding fields the supported
3489
- // csaf / markdown branches already surface — `json` is an intentional,
3490
- // operator-selectable format, not the fallback leak path.
2497
+ // A superset of `summary` carrying the full finding record, and the declared
2498
+ // bundle_format of several shipped playbooks — not the fallback below.
3491
2499
  if (format === 'json') {
3492
2500
  return {
3493
2501
  format: 'json',
@@ -3505,11 +2513,8 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3505
2513
  };
3506
2514
  }
3507
2515
 
3508
- // The fallback must NOT leak raw analyze + validate internals (matched
3509
- // CVEs, framework gaps, residual-risk statements) under an arbitrary
3510
- // "format" name — operators piping output to logging or third-party
3511
- // tooling could leak finding details just by typo'ing the format flag.
3512
- // Return the shape advertisement only.
2516
+ // The supported formats and nothing else: emitting analyze and validate
2517
+ // internals here leaks finding details on a typo'd format flag.
3513
2518
  return {
3514
2519
  format,
3515
2520
  note: 'Unknown format',
@@ -3517,34 +2522,20 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals,
3517
2522
  };
3518
2523
  }
3519
2524
 
3520
- // --- orchestrate: full run in one call ---
3521
-
3522
- /**
3523
- * v0.11.0 flat submission shape → v0.10.x nested shape. The flat shape is:
3524
- *
3525
- * {
3526
- * observations: {
3527
- * <artifact-id>: { captured, value, indicator?, result? } | "<precondition-value>",
3528
- * },
3529
- * verdict: { theater, classification, blast_radius }
3530
- * }
3531
- *
3532
- * Already-nested submissions pass through unchanged.
3533
- */
2525
+ // Translate the flat submission shape into the engine's nested one; an
2526
+ // already-nested submission passes through unchanged. Flat is:
2527
+ // { observations: { <artifact-id>: { captured, value, indicator?, result? }
2528
+ // | "<precondition-value>" },
2529
+ // verdict: { theater, classification, blast_radius } }
3534
2530
  function normalizeSubmission(submission, playbook) {
3535
2531
  if (!submission || typeof submission !== "object") return submission || {};
3536
2532
 
3537
- // signal_overrides must be a plain object. Without this guard, a
3538
- // non-object value (string "foo", array [...]) is spread into
3539
- // out.signal_overrides via `{ ...(submission.signal_overrides || {}) }`
3540
- // — spreading a string splatters it into { '0': 'f', '1': 'o', '2': 'o' },
3541
- // which confuses detect()'s indicator-id lookup. Strip and log instead.
2533
+ // signal_overrides must be a plain object: spreading a string splatters it
2534
+ // into { '0': 'f', '1': 'o', … } and confuses detect()'s indicator lookup.
3542
2535
  if (submission.signal_overrides !== undefined && submission.signal_overrides !== null
3543
2536
  && (typeof submission.signal_overrides !== 'object' || Array.isArray(submission.signal_overrides))) {
3544
- // Clone before mutating _runErrors so a frozen / shared input
3545
- // submission isn't modified in place. Pre-fix a caller passing a
3546
- // frozen submission (Object.freeze for safety, or a shared reference
3547
- // across parallel runs) threw uncaught on the _runErrors push.
2537
+ // Clone before touching _runErrors: the caller's submission may be frozen,
2538
+ // or shared across parallel runs.
3548
2539
  const carry = Array.isArray(submission._runErrors) ? submission._runErrors.slice() : [];
3549
2540
  pushRunError(carry, {
3550
2541
  kind: 'signal_overrides_invalid',
@@ -3554,17 +2545,12 @@ function normalizeSubmission(submission, playbook) {
3554
2545
  submission = { ...submission, signal_overrides: {}, _runErrors: carry };
3555
2546
  }
3556
2547
 
3557
- // v0.11.3 #71 fix: the CLI may inject `signals._bundle_formats` before
3558
- // calling normalize (for --format <fmt> support). Pre-0.11.3 normalize
3559
- // detected the injected `signals` key and bailed, leaving the flat
3560
- // `observations` / `verdict` untranslated and breaking detect. The shape
3561
- // detector now treats `observations` or `verdict` as authoritative for
3562
- // "this is flat" — even when nested keys also exist — and merges any
3563
- // pre-existing nested keys into the normalized result.
2548
+ // `observations` or `verdict` decides the shape even when nested keys are
2549
+ // present: the CLI injects `signals._bundle_formats` before calling this,
2550
+ // and reading that as "already nested" leaves the flat keys untranslated.
3564
2551
  const hasFlat = submission.observations || submission.verdict;
3565
2552
 
3566
2553
  if (!hasFlat) {
3567
- // Truly already-nested. Mark shape and return.
3568
2554
  if (!submission._original_shape) submission._original_shape = 'nested (v0.10.x)';
3569
2555
  return submission;
3570
2556
  }
@@ -3574,18 +2560,13 @@ function normalizeSubmission(submission, playbook) {
3574
2560
  signal_overrides: { ...(submission.signal_overrides || {}) },
3575
2561
  signals: { ...(submission.signals || {}) },
3576
2562
  precondition_checks: { ...(submission.precondition_checks || {}) },
3577
- // Carry per-indicator evidence locations through flat normalization so
3578
- // detect() can thread them onto firing indicators for SARIF results[].locations.
2563
+ // Carried through so detect() can thread them onto firing indicators for
2564
+ // SARIF results[].locations.
3579
2565
  ...(submission.evidence_locations && typeof submission.evidence_locations === 'object'
3580
2566
  ? { evidence_locations: submission.evidence_locations } : {}),
3581
2567
  _original_shape: 'flat (v0.11.0)',
3582
- // normalizeSubmission pushes structured errors (e.g.
3583
- // signal_overrides_invalid) onto submission._runErrors above. For flat
3584
- // submissions the fresh `out` literal built here loses that accumulator
3585
- // unless we forward it; run()'s harvest at the entry to detect/analyze
3586
- // reads agentSubmission._runErrors, so without the carry, flat
3587
- // submissions with invalid signal_overrides drop the errors before
3588
- // they can reach analyze.runtime_errors.
2568
+ // run() harvests the accumulator from agentSubmission._runErrors, which
2569
+ // the fresh `out` literal would otherwise drop.
3589
2570
  ...(Array.isArray(submission._runErrors) && submission._runErrors.length
3590
2571
  ? { _runErrors: submission._runErrors.slice() }
3591
2572
  : {}),
@@ -3593,9 +2574,6 @@ function normalizeSubmission(submission, playbook) {
3593
2574
  const knownPreconditions = new Set((playbook?._meta?.preconditions || []).map(p => p.id));
3594
2575
  const knownArtifacts = new Set((playbook?.phases?.look?.artifacts || []).map(a => a.id));
3595
2576
 
3596
- // v0.11.4 (#71): canonicalize indicator outcome strings here too so the
3597
- // signal_overrides object handed to detect() carries the runner's expected
3598
- // hit|miss|inconclusive vocabulary regardless of what the operator typed.
3599
2577
  const canonicalizeOutcome = (v) => {
3600
2578
  if (v === true || v === 'hit' || v === 'detected' || v === 'positive') return 'hit';
3601
2579
  if (v === false || v === 'miss' || v === 'no_hit' || v === 'no-hit' || v === 'clean' || v === 'clear' || v === 'not_hit' || v === 'ok' || v === 'pass' || v === 'negative') return 'miss';
@@ -3603,13 +2581,9 @@ function normalizeSubmission(submission, playbook) {
3603
2581
  return v; // leave unrecognized values for detect() to decide
3604
2582
  };
3605
2583
 
3606
- // v0.11.5 (#85): track which observation produced each signal_override so
3607
- // detect can emit `from_observation` on each indicator result. Diagnostic
3608
- // value for operators chasing "which observation drove this verdict".
3609
- //
3610
- // When two observations target the same indicator id, last-write-wins
3611
- // silently. Track discards in _signal_origins_collisions so analyze can
3612
- // surface analyze.signal_origins_with_collisions for batch evidence runs.
2584
+ // Which observation produced each signal_override, so detect can emit
2585
+ // `from_observation`. Two observations on one indicator is last-write-wins,
2586
+ // and the discard is recorded as a collision for analyze to publish.
3613
2587
  out._signal_origins = out._signal_origins || {};
3614
2588
  out._signal_origins_collisions = out._signal_origins_collisions || [];
3615
2589
  for (const [key, val] of Object.entries(submission.observations || {})) {
@@ -3619,15 +2593,10 @@ function normalizeSubmission(submission, playbook) {
3619
2593
  }
3620
2594
  if (typeof val === "object" && val !== null) {
3621
2595
  const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
3622
- // Preserve every evidence-bearing key the observation carried, not just
3623
- // `value`. An observation whose secret/path lives under a non-`value`
3624
- // key (e.g. { path, matched, reason } — a natural collector shape) was
3625
- // previously collapsed to { value: undefined, captured } here, silently
3626
- // discarding the actual evidence. Two observations capturing DIFFERENT
3627
- // secrets under path/matched then hashed and diffed byte-identical, so
3628
- // `attest diff` reported a false "unchanged" and masked real drift.
3629
- // The reserved control keys (artifact/indicator/result) drive
3630
- // signal_overrides below and are intentionally not echoed as evidence.
2596
+ // Every evidence-bearing key survives, not just `value`: a collector shape
2597
+ // like { path, matched, reason } would collapse to { value: undefined,
2598
+ // captured }, so two observations capturing DIFFERENT secrets hash
2599
+ // byte-identical. artifact/indicator/result are control keys, not evidence.
3631
2600
  const { artifact: _a, indicator: _i, result: _r, captured: _c, value: _v, ...evidence } = val;
3632
2601
  const normalizedArtifact = { captured: val.captured !== false };
3633
2602
  if (val.value !== undefined) normalizedArtifact.value = val.value;
@@ -3639,9 +2608,6 @@ function normalizeSubmission(submission, playbook) {
3639
2608
  if (val.indicator && val.result !== undefined) {
3640
2609
  const newVerdict = canonicalizeOutcome(val.result);
3641
2610
  if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
3642
- // Collision: a prior observation already set this indicator.
3643
- // Record the prior (which is now discarded) into the collision
3644
- // log, then overwrite with the new one (last-write-wins).
3645
2611
  out._signal_origins_collisions.push({
3646
2612
  indicator_id: val.indicator,
3647
2613
  source_observation_key: out._signal_origins[val.indicator],
@@ -3661,18 +2627,9 @@ function normalizeSubmission(submission, playbook) {
3661
2627
  if (v.classification) out.signals.detection_classification = v.classification;
3662
2628
  if (v.blast_radius !== undefined) out.signals.blast_radius_score = v.blast_radius;
3663
2629
 
3664
- // Carry over precondition_checks if the operator supplied them at the top
3665
- // level even in the flat shape.
3666
- //
3667
- // The prior `Object.assign(out.precondition_checks,
3668
- // submission.precondition_checks)` form re-invoked the `__proto__` setter when
3669
- // the operator submitted JSON containing a `__proto__` key. JSON.parse keeps
3670
- // `__proto__` as an own data property (CreateDataProperty), but Object.assign
3671
- // reads it via `[[Get]]` and writes via `[[Set]]`, which DOES trigger the
3672
- // prototype-rebinding setter. The polluted prototype is confined to
3673
- // `out.precondition_checks` (not global Object.prototype), but any future code
3674
- // path that calls `.hasOwnProperty()` directly on the bag would observe the
3675
- // pollution. Switch to own-key iteration so the prototype stays unmodified.
2630
+ // Own-key iteration, never Object.assign: JSON.parse keeps a submitted
2631
+ // `__proto__` as an own data property, but Object.assign writes it through
2632
+ // [[Set]] and triggers the prototype-rebinding setter on the bag.
3676
2633
  if (submission.precondition_checks) {
3677
2634
  for (const k of Object.keys(submission.precondition_checks)) {
3678
2635
  if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
@@ -3683,13 +2640,9 @@ function normalizeSubmission(submission, playbook) {
3683
2640
  return out;
3684
2641
  }
3685
2642
 
3686
- /**
3687
- * Smart precondition auto-detect (redesign #9). Some preconditions are
3688
- * mechanically answerable by the runner itself — host platform, cwd
3689
- * readability, command-on-PATH. The AI shouldn't have to declare these;
3690
- * we resolve them ourselves and only escalate to AI declaration when the
3691
- * check requires intent (e.g. "operator authorized this scan").
3692
- */
2643
+ // Answer the preconditions the runner can answer itself — host platform, cwd
2644
+ // readability, command-on-PATH — leaving only the ones that require intent
2645
+ // ("operator authorized this scan") for the AI to declare.
3693
2646
  function autoDetectPreconditions(submission, playbook) {
3694
2647
  const fs = require('fs');
3695
2648
  const out = { ...(submission || {}) };
@@ -3712,17 +2665,14 @@ function autoDetectPreconditions(submission, playbook) {
3712
2665
  const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', [cmdName], { stdio: 'ignore' });
3713
2666
  out.precondition_checks[pc.id] = probe.status === 0;
3714
2667
  }
3715
- // Intent-requiring checks (e.g. "operator_authorized == true") are NOT
3716
- // auto-resolved — the AI / operator still declares them. We leave them
3717
- // undefined and the preflight gate handles missing values per on_fail.
2668
+ // An intent check ("operator_authorized == true") is left undefined for
2669
+ // the operator to declare; preflight handles the missing value per on_fail.
3718
2670
  }
3719
2671
  return out;
3720
2672
  }
3721
2673
 
2674
+ // The seven phases in one call.
3722
2675
  function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3723
- // Catalog corruption blocks runs cleanly. Probed lazily here (before the
3724
- // analyze path) rather than at module load, so cheap verbs don't pay the
3725
- // catalog-parse cost.
3726
2676
  const xrefErr = getXrefLoadError();
3727
2677
  if (xrefErr) {
3728
2678
  return {
@@ -3737,7 +2687,6 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3737
2687
  try {
3738
2688
  playbook = loadPlaybook(playbookId);
3739
2689
  } catch (e) {
3740
- // loadPlaybook failure → structured error (not crash).
3741
2690
  return {
3742
2691
  ok: false,
3743
2692
  blocked_by: 'playbook_not_found',
@@ -3746,10 +2695,8 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3746
2695
  };
3747
2696
  }
3748
2697
 
3749
- // Validate directiveId before any phase runs. An unknown id would
3750
- // otherwise throw inside analyze() / findDirective() uncaught, surfacing
3751
- // as a 500-style stack trace; instead return a clean structured error
3752
- // with the valid directive list.
2698
+ // Before any phase runs: an unknown id otherwise throws uncaught inside
2699
+ // findDirective() and surfaces as a stack trace.
3753
2700
  const validDirectives = (playbook.directives || []).map(d => d.id);
3754
2701
  if (!validDirectives.includes(directiveId)) {
3755
2702
  return {
@@ -3760,27 +2707,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3760
2707
  };
3761
2708
  }
3762
2709
 
3763
- // v0.11.0: accept flat submission shape (observations + verdict). Normalize
3764
- // to the engine's internal nested shape before preflight/detect. Smart
3765
- // precondition auto-detect (redesign #9) fires here when the cwd is readable
3766
- // / the host platform matches — the runner can answer those itself rather
3767
- // than blocking on AI declaration.
3768
2710
  agentSubmission = normalizeSubmission(agentSubmission, playbook);
3769
- // Capture pre-autoDetect submission preconditions so we report
3770
- // user-declared provenance, not engine-auto-resolved values.
2711
+ // Captured before auto-detect so the provenance report distinguishes what
2712
+ // the operator declared from what the engine resolved.
3771
2713
  const originalSubmissionPCs = { ...(agentSubmission.precondition_checks || {}) };
3772
2714
  agentSubmission = autoDetectPreconditions(agentSubmission, playbook);
3773
2715
 
3774
- // precondition_checks merge order is submission → runOpts (runOpts
3775
- // wins on collision). This is intentional: runOpts represents the most
3776
- // recent caller intent (CLI flags / programmatic injection from a host
3777
- // process), whereas submission was captured earlier during evidence
3778
- // collection. The order is documented here AND surfaced as
3779
- // preflight.precondition_check_source on the result so callers can see
3780
- // whether the value came from the submission, runOpts, or both
3781
- // (merged with runOpts winning). Provenance reports the ORIGINAL submission
3782
- // contents — autoDetectPreconditions adds engine-derived values that
3783
- // wouldn't be meaningful as "submission" provenance.
2716
+ // precondition_checks merge submission → runOpts, runOpts winning as the
2717
+ // most recent caller intent; the submission was captured earlier, during
2718
+ // evidence collection. precondition_check_source names which side won.
3784
2719
  const fullSubmissionPCs = agentSubmission.precondition_checks || {};
3785
2720
  const runOptsPCs = runOpts.precondition_checks || {};
3786
2721
  const mergedPCs = { ...fullSubmissionPCs, ...runOptsPCs };
@@ -3788,23 +2723,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3788
2723
  for (const k of Object.keys(mergedPCs)) {
3789
2724
  const inOrigSub = Object.prototype.hasOwnProperty.call(originalSubmissionPCs, k);
3790
2725
  const inRun = Object.prototype.hasOwnProperty.call(runOptsPCs, k);
3791
- // A key present in neither the original submission nor runOpts was filled
3792
- // in by autoDetectPreconditions (engine-derived host/platform facts) —
3793
- // report it as 'auto', not 'submission'. Otherwise: submission ∩ runOpts =
3794
- // a genuine programmatic override of a submitted value ('merged');
3795
- // runOpts-only = 'runOpts'; submission-only = 'submission'.
2726
+ // In neither side means autoDetectPreconditions supplied it ('auto'); in
2727
+ // both means a programmatic override of a submitted value ('merged').
3796
2728
  if (!inOrigSub && !inRun) pcSource[k] = 'auto';
3797
2729
  else pcSource[k] = (inOrigSub && inRun) ? 'merged' : (inRun ? 'runOpts' : 'submission');
3798
2730
  }
3799
2731
  const pre = preflight(playbook, { ...runOpts, precondition_checks: mergedPCs });
3800
2732
  if (!pre.ok) {
3801
- // Blocked results MUST carry playbook_id + directive_id at the
3802
- // result root, the same as successful results. Without this,
3803
- // consumers iterating results[] can't identify which playbook
3804
- // produced a preflight failure without joining against
3805
- // playbooks_run[] by array index. `verdict:"blocked"` +
3806
- // `summary_line` keep the flat result-envelope shape consistent
3807
- // across both branches.
2733
+ // A blocked result carries the same envelope fields as a successful one, so
2734
+ // a consumer iterating results[] can name the playbook that failed.
3808
2735
  const summaryLine = capSummary(`${playbookId}: blocked at preflight (${pre.blocked_by || 'unknown'}) — ${pre.reason || ''}`);
3809
2736
  return {
3810
2737
  ok: false,
@@ -3822,21 +2749,13 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3822
2749
  };
3823
2750
  }
3824
2751
 
3825
- // Cross-process mutex lock for this run. Use the diagnostic variant so a
3826
- // CONFIRMED live foreign holder BLOCKS the run. The bare acquireLock returned
3827
- // null on a lost race (the TOCTOU window between preflight's check and the
3828
- // acquire) and the run then proceeded UNLOCKED, defeating the mutex. Block
3829
- // only on `held_by_live_pid` — same-PID reentrancy and FS quirks still
3830
- // proceed best-effort (lockPath null), so no legitimate nested/edge case
3831
- // regresses. Released in the finally block.
2752
+ // The diagnostic variant, because bare acquireLock returns null on a race
2753
+ // lost in the TOCTOU window since preflight's check, and the run would then
2754
+ // proceed UNLOCKED. Released in the finally block below.
3832
2755
  const lockResult = acquireLockDiagnostic(playbookId);
3833
- // Block ONLY on a confirmed live foreign holder with a real numeric pid.
3834
- // acquireLockDiagnostic also returns reason:'held_by_live_pid' with
3835
- // holder_pid:null for a MALFORMED/truncated lockfile (it can't parse a pid to
3836
- // probe). Blocking on that would let one corrupted lockfile left by a crash
3837
- // deny every future run forever. A null-pid (malformed) lock instead falls
3838
- // through to proceed best-effort (lockPath null) — matching how the preflight
3839
- // mutex check treats unparsable lockfiles as stale.
2756
+ // Only on a confirmed live foreign holder with a real pid. A malformed
2757
+ // lockfile reports held_by_live_pid too, with holder_pid null, and blocking on
2758
+ // that lets one file left by a crash deny every future run forever.
3840
2759
  if (!lockResult.ok && lockResult.reason === 'held_by_live_pid'
3841
2760
  && Number.isInteger(lockResult.holder_pid) && lockResult.holder_pid > 0) {
3842
2761
  return {
@@ -3854,26 +2773,11 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3854
2773
  }
3855
2774
  const lockPath = lockResult.ok ? lockResult.path : null;
3856
2775
  _activeRuns.add(playbookId);
3857
- // Parse the playbook once at run() entry and thread the parsed object
3858
- // through each phase via runOpts._playbookCache. Each phase otherwise
3859
- // calls loadPlaybook() independently; for a single run that's seven
3860
- // reads + parses of the same file. Caching saves the redundant I/O +
3861
- // JSON parses.
3862
- //
3863
- // session_id is generated ONCE here and threaded into close() via
3864
- // cachedRunOpts.session_id so CSAF tracking.id / OpenVEX @id / product
3865
- // PURLs / on-disk attestation filenames all share one identifier.
3866
- // Without the single-source-of-truth, close() would mint its own id
3867
- // and operators correlating attestation files to embedded bundle URNs
3868
- // would see mismatches.
3869
- //
3870
- // v0.12.27: when runOpts.bundleDeterministic is set AND the operator did
3871
- // not pass --session-id, derive the session_id from the submission shape
3872
- // so two runs against identical evidence produce the same id (and
3873
- // therefore the same CSAF tracking.id / OpenVEX @id / attestation file
3874
- // name). Mirrors the evidence_hash path further down but is computed
3875
- // here so close() can thread it through. Operator-supplied --session-id
3876
- // still wins on collision.
2776
+ // The playbook is parsed once and threaded through every phase as
2777
+ // runOpts._playbookCache. session_id is minted once, so the CSAF tracking.id,
2778
+ // OpenVEX @id, product PURLs and attestation filename carry one correlatable
2779
+ // identifier. Under bundleDeterministic it derives from the submission, so two
2780
+ // runs over identical evidence agree; --session-id still wins.
3877
2781
  let sessionId;
3878
2782
  if (runOpts.session_id) {
3879
2783
  sessionId = runOpts.session_id;
@@ -3884,12 +2788,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3884
2788
  .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
3885
2789
  .digest('hex');
3886
2790
  } catch (e) {
3887
- // canonicalStringify deliberately throws (EVIDENCE_TOO_DEEP) on
3888
- // pathological nesting. The mutex lockfile and the _activeRuns entry
3889
- // are already held here, but the protecting try/finally below has
3890
- // not opened yet — release both before rethrowing, or the leaked
3891
- // lockfile blocks every subsequent run of this playbook for as long
3892
- // as this PID lives.
2791
+ // canonicalStringify throws EVIDENCE_TOO_DEEP on pathological nesting,
2792
+ // and the try/finally below has not opened yet — a leaked lockfile would
2793
+ // block every later run of this playbook for this PID's lifetime.
3893
2794
  _activeRuns.delete(playbookId);
3894
2795
  releaseLock(lockPath);
3895
2796
  throw e;
@@ -3902,24 +2803,19 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3902
2803
  sessionId = crypto.randomBytes(8).toString('hex');
3903
2804
  }
3904
2805
  const cachedRunOpts = { ...runOpts, _playbookCache: playbook, session_id: sessionId };
3905
- // Run-time error accumulator for evalCondition regex failures and other
3906
- // non-fatal anomalies surfaced into analyze.runtime_errors[].
2806
+ // The run-level accumulator behind analyze.runtime_errors[].
3907
2807
  const runErrors = [];
3908
2808
  cachedRunOpts._runErrors = runErrors;
3909
- // normalizeSubmission may push structured errors (e.g.
3910
- // signal_overrides_invalid) onto submission._runErrors. Splice them
3911
- // into the run-level accumulator so analyze.runtime_errors[] surfaces
3912
- // them, and strip the field off the submission so it doesn't pollute
3913
- // the evidence_hash digest (the hash canonicalizes the submission and
3914
- // a non-deterministic _runErrors would change it).
2809
+ // Splice in what normalizeSubmission pushed, then strip the field off the
2810
+ // submission: the evidence_hash canonicalizes the submission, and a
2811
+ // non-deterministic _runErrors would change the digest.
3915
2812
  if (Array.isArray(agentSubmission._runErrors) && agentSubmission._runErrors.length) {
3916
2813
  runErrors.push(...agentSubmission._runErrors);
3917
2814
  }
3918
2815
  if (agentSubmission && Object.prototype.hasOwnProperty.call(agentSubmission, '_runErrors')) {
3919
2816
  delete agentSubmission._runErrors;
3920
2817
  }
3921
- // Phases the runner should SKIP execution for, based on skip_phase
3922
- // preconditions surfaced in preflight.issues.
2818
+ // Phases to skip, from the skip_phase preconditions preflight surfaced.
3923
2819
  const skipPhases = new Set();
3924
2820
  for (const issue of (pre.issues || [])) {
3925
2821
  if (issue.kind === 'precondition_skip' && issue.skip_phase) {
@@ -3948,10 +2844,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3948
2844
  observations_received: [],
3949
2845
  signals_received: []
3950
2846
  };
3951
- // analyze() must still run, but with an empty submission so it doesn't
3952
- // resolve indicator hits against a non-existent detect result.
2847
+ // analyze still runs, on an empty submission so it cannot resolve
2848
+ // indicator hits against a detect result that never happened.
3953
2849
  phases.analyze = analyze(playbookId, directiveId, phases.detect, {}, cachedRunOpts);
3954
- // Annotate analyze with the skip vocabulary so consumers can branch.
3955
2850
  phases.analyze.classification = 'skipped';
3956
2851
  } else {
3957
2852
  phases.detect = detect(playbookId, directiveId, agentSubmission, cachedRunOpts);
@@ -3960,19 +2855,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3960
2855
  phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
3961
2856
  phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
3962
2857
 
3963
- // analyze() already sliced runOpts._runErrors into
3964
- // phases.analyze.runtime_errors at return time. Validate + close may
3965
- // have pushed additional regex errors AFTER analyze returned; surface
3966
- // those onto phases.analyze.runtime_errors so the field reflects every
3967
- // regex failure in the run. De-dupe by JSON shape so the analyze-time
3968
- // snapshot doesn't double-count.
2858
+ // analyze() snapshotted the accumulator when it returned; validate and
2859
+ // close push after that, so late entries merge back in.
3969
2860
  if (runErrors.length && phases.analyze) {
3970
- // `_truncated` sentinels are pushed by pushRunError when a per-kind
3971
- // or total cap fires. They aggregate via in-place `dropped` increments,
3972
- // so the same sentinel object is BOTH in the analyze snapshot AND in
3973
- // the late-push `runErrors` ref. Skip them on the dedupe-merge pass
3974
- // to keep the snapshot's authoritative dropped-count, rather than
3975
- // double-stamping a second sentinel with the same `dropped` value.
2861
+ // A `_truncated` sentinel aggregates through in-place `dropped`
2862
+ // increments, so the same object sits in both arrays; skipping it keeps
2863
+ // one authoritative count instead of stamping a duplicate.
3976
2864
  const existing = new Set(
3977
2865
  (phases.analyze.runtime_errors || [])
3978
2866
  .filter(e => !(e && e.kind === '_truncated'))
@@ -3984,16 +2872,10 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
3984
2872
  }
3985
2873
  }
3986
2874
 
3987
- // evidence_hash binds the operator's submission to the verdict. The
3988
- // hash must include the canonicalized submission (observations,
3989
- // signal_overrides, signals) — keying it on only { playbook, directive,
3990
- // cves, rwep, classification } would let two operators with completely
3991
- // different evidence collide on the same hash whenever their
3992
- // classifications match. Use SHA-256 over the recursively sorted
3993
- // submission. `captured_at` and other timestamp-like fields are
3994
- // INTENTIONALLY excluded so that re-running with the same submission
3995
- // produces the same hash — `reattest` relies on this to detect drift
3996
- // (different submission → different hash → drift exists).
2875
+ // evidence_hash covers the canonicalized submission itself: keyed on only
2876
+ // { playbook, directive, cves, rwep, classification }, two operators with
2877
+ // different evidence collide whenever their classifications match.
2878
+ // Timestamps are excluded so one submission always hashes the same.
3997
2879
  const submissionDigest = crypto.createHash('sha256')
3998
2880
  .update(canonicalStringify(extractSubmissionForHash(agentSubmission)))
3999
2881
  .digest('hex');
@@ -4007,24 +2889,14 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
4007
2889
  }))
4008
2890
  .digest('hex');
4009
2891
 
4010
- // Hoist the operator-facing summary to the result root so a `ci`
4011
- // consumer does not have to walk into phases.detect.classification
4012
- // + phases.analyze.rwep.adjusted separately to answer the most
4013
- // common ops question ("did this playbook detect anything?").
4014
- //
4015
- // Verdict source: phases.detect.classification is the canonical
4016
- // signal — one of detected / not_detected / inconclusive / pending
4017
- // / skipped. validate() does NOT carry a `verdict` field, so
4018
- // sourcing the hoist from phases.validate.verdict would degrade
4019
- // every non-blocked result to the fallback "inconclusive".
2892
+ // Hoisted to the result root so a `ci` consumer answers "did this detect
2893
+ // anything?" without walking into phases. detect.classification is the
2894
+ // canonical verdict — validate() carries no `verdict` field.
4020
2895
  const verdict = (phases.detect && phases.detect.classification) || 'inconclusive';
4021
2896
  const rwepScore = phases.analyze && phases.analyze.rwep && typeof phases.analyze.rwep.adjusted === 'number'
4022
2897
  ? phases.analyze.rwep.adjusted : null;
4023
- // Top finding = first matched CVE-id when present; otherwise the dominant
4024
- // FIRED indicator's id (the signal that actually drove the verdict), not
4025
- // the verdict string itself. Echoing the classification made top_finding
4026
- // carry no information beyond `verdict` and duplicated it in summary_line
4027
- // (e.g. "detected (rwep=10, detected, ...)"). The full set lives in phases.
2898
+ // The first matched CVE id, else the fired indicator that drove the
2899
+ // verdict — never the classification, which duplicates `verdict`.
4028
2900
  let topFinding = null;
4029
2901
  if (phases.analyze && Array.isArray(phases.analyze.matched_cves) && phases.analyze.matched_cves.length) {
4030
2902
  topFinding = phases.analyze.matched_cves[0].cve_id;
@@ -4034,14 +2906,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
4034
2906
  && phases.detect.classification !== 'not_detected'
4035
2907
  && phases.detect.classification !== 'inconclusive'
4036
2908
  && phases.detect.classification !== 'pending') {
4037
- // Only on a real detection verdict: surface the FIRED indicator that
4038
- // best explains the result. Gating on the verdict keeps a stray hit on
4039
- // an inconclusive/not-detected run from advertising a finding. Prefer the
4040
- // indicator that actually drove the RWEP score — the greatest
4041
- // weight_applied among fired, weight-bearing rwep_inputs — so the
4042
- // headline names the finding that produced the number shown beside it.
4043
- // Fall back to the dominant fired indicator (deterministic/high
4044
- // confidence), then the first hit, when no weighted signal fired.
2909
+ // Only on a real detection verdict, so a stray hit on an inconclusive run
2910
+ // cannot advertise a finding. The indicator that drove the RWEP score
2911
+ // wins, then the dominant fired indicator, then the first hit.
4045
2912
  const breakdown = (phases.analyze.rwep && Array.isArray(phases.analyze.rwep.breakdown))
4046
2913
  ? phases.analyze.rwep.breakdown : [];
4047
2914
  const driver = breakdown
@@ -4052,12 +2919,9 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
4052
2919
  if (driver && driver.signal_id) topFinding = driver.signal_id;
4053
2920
  else if (dominant && dominant.id) topFinding = dominant.id;
4054
2921
  }
4055
- // Evidence completeness: indicators-evaluated vs indicators-known
4056
- // distinguishes "ran fully and found nothing" from "couldn't
4057
- // actually evaluate". Without this, the two states look identical
4058
- // at the result-root level — a not_detected verdict with zero
4059
- // indicators-evaluated reads the same as one with every indicator
4060
- // evaluated and miss.
2922
+ // Evaluated against known separates "ran fully and found nothing" from
2923
+ // "could not evaluate" — a not_detected verdict over zero indicators
2924
+ // otherwise reads exactly like one where every indicator missed.
4061
2925
  const indicatorsKnown = (playbook.phases && playbook.phases.detect && playbook.phases.detect.indicators)
4062
2926
  ? playbook.phases.detect.indicators.length : null;
4063
2927
  const indicatorsEvaluated = phases.detect && typeof phases.detect.indicators_evaluated_count === 'number'
@@ -4076,10 +2940,7 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
4076
2940
  playbook_id: playbookId,
4077
2941
  directive_id: directiveId,
4078
2942
  session_id: sessionId,
4079
- // Flat top-level fields (B2 + B6 + B9). Operators no longer need
4080
- // to spelunk through phases.* to get the answer to the most
4081
- // common question. The phases tree is still present below for
4082
- // anyone who needs the full CSAF / analyze / detect dumps.
2943
+ // The flat answer; the phases tree below carries the full dumps.
4083
2944
  verdict,
4084
2945
  rwep_score: rwepScore,
4085
2946
  top_finding: topFinding,
@@ -4090,15 +2951,12 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
4090
2951
  evidence_hash: evidenceHash,
4091
2952
  submission_digest: submissionDigest,
4092
2953
  preflight_issues: pre.issues,
4093
- // Non-fatal collector notices (e.g. a file skipped for exceeding the
4094
- // scan size limit) surfaced from the submission so a `collect | run`
4095
- // consumer can see what the collector could not scan. Advisory only:
4096
- // never affects verdict, rwep, or evidence_completeness.
2954
+ // What the collector could not scan. Advisory only — never affects
2955
+ // verdict, rwep or evidence_completeness.
4097
2956
  ...(Array.isArray(agentSubmission.collector_errors) && agentSubmission.collector_errors.length
4098
2957
  ? { collector_warnings: agentSubmission.collector_errors }
4099
2958
  : {}),
4100
- // Source provenance for precondition_checks. Shape:
4101
- // { '<pc-id>': 'submission' | 'runOpts' | 'merged', ... }
2959
+ // { '<pc-id>': 'submission' | 'runOpts' | 'merged' | 'auto', … }
4102
2960
  precondition_check_source: pcSource,
4103
2961
  phases
4104
2962
  };
@@ -4108,25 +2966,15 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
4108
2966
  }
4109
2967
  }
4110
2968
 
4111
- // --- helpers ---
4112
-
4113
- /**
4114
- * Deterministic JSON stringification with recursively sorted keys.
4115
- * Without sorted keys two semantically identical submissions ({a:1, b:2}
4116
- * vs {b:2, a:1}) would hash to different digests, breaking reattest's
4117
- * "same submission → same hash" contract. Arrays preserve order
4118
- * (submission order is meaningful for evidence). null + primitives pass
4119
- * through directly. Avoids JSON.stringify's replacer indirection because
4120
- * a top-level array would otherwise miss the canonicalization recursion.
4121
- */
2969
+ // Deterministic JSON with recursively sorted keys: {a:1, b:2} and {b:2, a:1}
2970
+ // must hash alike or reattest's "same submission → same hash" breaks. Array
2971
+ // order is preserved — submission order is meaningful for evidence. Throws
2972
+ // EVIDENCE_TOO_DEEP past CANONICAL_MAX_DEPTH.
4122
2973
  const CANONICAL_MAX_DEPTH = 200;
4123
2974
  function canonicalStringify(v, _depth = 0) {
4124
2975
  if (v === null || typeof v !== 'object') return JSON.stringify(v);
4125
- // Bounded recursion: adversarial (or accidental) deeply-nested evidence
4126
- // would otherwise overflow the stack with an opaque "internal error". This
4127
- // runs on every run() (evidence_hash + session_id derivation), so the guard
4128
- // turns a crash into an actionable rejection. 200 levels is far beyond any
4129
- // legitimate submission.
2976
+ // Deeply-nested evidence would overflow the stack with an opaque internal
2977
+ // error; 200 is far beyond any legitimate submission.
4130
2978
  if (_depth > CANONICAL_MAX_DEPTH) {
4131
2979
  const e = new Error(`evidence nesting exceeds the maximum depth of ${CANONICAL_MAX_DEPTH} — flatten the submission`);
4132
2980
  e.code = 'EVIDENCE_TOO_DEEP';
@@ -4137,20 +2985,12 @@ function canonicalStringify(v, _depth = 0) {
4137
2985
  return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(v[k], _depth + 1)).join(',') + '}';
4138
2986
  }
4139
2987
 
4140
- // Re-key an artifacts map by the stable indicator id each artifact was bound to
4141
- // (recovered by inverting _signal_origins: indicator-id -> observation-key), so
4142
- // the evidence_hash reflects the evidence VALUE + its stable binding rather than
4143
- // the operator's free-text observation label. Without this, two submissions with
4144
- // identical (indicator, value) evidence under different observation keys
4145
- // (obs-kver vs x1) hashed differently and attest/reattest reported false drift —
4146
- // the attest diff re-keys the COMPARISON (bin/exceptd.js); the hash itself was
4147
- // not covered. Collision-safe (mirrors that helper): re-key only when the stable
4148
- // id is not already a DISTINCT original key and has not been claimed by an
4149
- // earlier entry, else keep the original key so no artifact is silently dropped.
2988
+ // Re-key an artifacts map by the stable indicator id, recovered by inverting
2989
+ // _signal_origins, so evidence_hash reflects the evidence VALUE and its binding
2990
+ // rather than the operator's free-text observation label. bin/exceptd.js re-keys
2991
+ // the attest COMPARISON the same way. Collision-safe: a stable id already used
2992
+ // as a distinct key keeps the original, dropping nothing.
4150
2993
  function _rekeyArtifactsByStableId(artifacts, signalOrigins) {
4151
- // The sole caller (extractSubmissionForHash) only invokes this inside
4152
- // `if (sub.artifacts && typeof sub.artifacts === 'object')`, so `artifacts`
4153
- // is always a non-null object — guard only on the re-key map being usable.
4154
2994
  if (!signalOrigins || typeof signalOrigins !== 'object') return artifacts;
4155
2995
  const obsKeyToIndicator = {};
4156
2996
  for (const [indicatorId, obsKey] of Object.entries(signalOrigins)) {
@@ -4167,20 +3007,13 @@ function _rekeyArtifactsByStableId(artifacts, signalOrigins) {
4167
3007
  return out;
4168
3008
  }
4169
3009
 
4170
- /**
4171
- * Pick the operator-meaningful fields out of the normalized submission
4172
- * for hashing. captured_at, _signal_origins, _signal_origins_collisions,
4173
- * and _original_shape are intentionally excluded — they're either
4174
- * timestamps (would break "same submission → same hash") or runner-internal
4175
- * provenance metadata that isn't part of what the operator submitted.
4176
- */
3010
+ // The operator-meaningful fields of a normalized submission, for hashing.
3011
+ // captured_at, _signal_origins, _signal_origins_collisions and _original_shape
3012
+ // are excluded as timestamps, which break "same submission → same hash", or as
3013
+ // runner-internal provenance the operator never submitted.
4177
3014
  function extractSubmissionForHash(sub) {
4178
3015
  if (!sub || typeof sub !== 'object') return {};
4179
3016
  const pick = {};
4180
- // Strip captured_at from artifact entries so timestamp drift doesn't
4181
- // perturb the digest. The semantic content (value + captured-ness +
4182
- // optional indicator binding) is what matters for "did the operator
4183
- // submit the same evidence?".
4184
3017
  if (sub.artifacts && typeof sub.artifacts === 'object') {
4185
3018
  const stripped = {};
4186
3019
  for (const [k, v] of Object.entries(sub.artifacts)) {
@@ -4191,45 +3024,26 @@ function extractSubmissionForHash(sub) {
4191
3024
  stripped[k] = v;
4192
3025
  }
4193
3026
  }
4194
- // Re-key by the stable indicator id so the digest reflects the evidence
4195
- // VALUE + its binding, never the operator's free-text observation label.
4196
- // Identical (indicator, value) evidence under different observation keys
4197
- // must produce the SAME evidence_hash / submission_digest / session_id.
4198
3027
  pick.artifacts = _rekeyArtifactsByStableId(stripped, sub._signal_origins);
4199
3028
  }
4200
3029
  if (sub.signal_overrides && typeof sub.signal_overrides === 'object') {
4201
3030
  pick.signal_overrides = sub.signal_overrides;
4202
3031
  }
4203
3032
  if (sub.signals && typeof sub.signals === 'object') {
4204
- // vex_filter and vex_fixed may be Sets — convert to sorted arrays so
4205
- // canonicalStringify can serialize them.
3033
+ // vex_filter and vex_fixed arrive as Sets; sorted arrays serialize.
4206
3034
  const signals = {};
4207
3035
  for (const [k, v] of Object.entries(sub.signals)) {
4208
- // Underscore-prefixed signal keys are runner-internal / output-rendering
4209
- // directives, NOT operator evidence: `_bundle_formats` (the CLI sets it
4210
- // from `--format sarif|openvex|csaf`) only chooses which bundle close()
4211
- // renders — the analyzed posture is identical. Hashing it broke the
4212
- // "same evidence → same evidence_hash" contract: two runs over identical
4213
- // evidence that differ only in `--format` produced different digests and
4214
- // a self-contradicting attest diff. Exclude every `_`-prefixed signal key
4215
- // (matches the file-wide "underscore = internal" convention). vex_filter /
4216
- // vex_fixed are deliberately NOT excluded — they DROP CVEs from
4217
- // matched_cves and change the finding, so they are posture-affecting
4218
- // evidence; excluding them would make reattest blind to a VEX-driven
4219
- // posture change (a real drift hidden behind an unchanged hash).
3036
+ // An underscore-prefixed signal is a render directive, not evidence:
3037
+ // hashing `_bundle_formats` makes two runs differing only in --format
3038
+ // produce different digests. vex_filter and vex_fixed stay IN — they drop
3039
+ // CVEs and change the finding.
4220
3040
  if (k.startsWith('_')) continue;
4221
3041
  if (v instanceof Set) signals[k] = Array.from(v).sort();
4222
3042
  else signals[k] = v;
4223
3043
  }
4224
- // Omit an empty signals bag rather than recording `signals: {}`. A
4225
- // submission whose ONLY signal was a `_`-prefixed render directive
4226
- // (`{ _bundle_formats: [...] }`, injected by `ci`/`run --format`) reduces to
4227
- // {} after the filter above; the baseline no-format submission carries no
4228
- // `signals` key at all. Recording `{}` vs omitting it would hash identical
4229
- // evidence as `{signals:{}}` vs no-signals and reintroduce the exact
4230
- // --format-driven evidence_hash / session-id drift this exclusion exists to
4231
- // prevent. Only attach signals when at least one operator-meaningful key
4232
- // survived.
3044
+ // An empty bag is omitted, not recorded as `signals: {}`: a submission whose
3045
+ // only signal was a render directive reduces to {} here while the baseline
3046
+ // has no `signals` key, reintroducing the --format drift filtered above.
4233
3047
  if (Object.keys(signals).length > 0) pick.signals = signals;
4234
3048
  }
4235
3049
  if (sub.precondition_checks && typeof sub.precondition_checks === 'object') {
@@ -4244,13 +3058,10 @@ function extractSubmissionForHash(sub) {
4244
3058
  return pick;
4245
3059
  }
4246
3060
 
4247
- // Identity fields a `contains`/`includes` membership test targets when the
4248
- // array holds objects (e.g. jurisdiction_obligations records). Membership is
4249
- // field-scoped to these so a non-identity field (clock_starts, obligation,
4250
- // regulation, a free-form tag) that happens to equal the member cannot satisfy
4251
- // the predicate. `jurisdiction` is the only field the catalog's object-array
4252
- // `contains` conditions target today; the allowlist is the seam to extend if a
4253
- // future condition deliberately matches a different identity field.
3061
+ // The identity fields a `contains`/`includes` test targets when the array holds
3062
+ // objects. Scoping membership to these keeps a non-identity field that happens
3063
+ // to equal the member (a clock_starts of 'detect_confirmed', a free-form tag
3064
+ // reading 'EU') from satisfying the predicate.
4254
3065
  const OBJECT_MEMBERSHIP_FIELDS = ['jurisdiction'];
4255
3066
 
4256
3067
  function evalCondition(expr, ctx, playbook) {
@@ -4261,44 +3072,23 @@ function evalCondition(expr, ctx, playbook) {
4261
3072
  if (expr === 'true') return true;
4262
3073
  if (expr === 'false') return false;
4263
3074
 
4264
- // Honor operator precedence: OR is lower precedence than AND, so split on OR
4265
- // first. splitAtTopLevel walks the expression depth-aware so parens correctly
4266
- // group sub-expressions — i.e. `A OR (B AND C)` parses with B,C as one AND
4267
- // group rather than splitting at the inner AND.
3075
+ // OR binds looser than AND, so it splits first; splitAtTopLevel is
3076
+ // depth-aware, so `A OR (B AND C)` keeps B and C as one group.
4268
3077
  const orParts = splitAtTopLevel(expr, 'OR');
4269
3078
  if (orParts.length > 1) return orParts.some(s => evalCondition(s, ctx, playbook));
4270
3079
 
4271
3080
  const andParts = splitAtTopLevel(expr, 'AND');
4272
3081
  if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
4273
3082
 
4274
- // Quantifier prefix: catalog conditions write `any <path> <op> <value>`
4275
- // (e.g. framework.json's feeds_into `any compliance_theater_check.verdict ==
4276
- // 'theater' …`). An unhandled `any `/`all ` prefix is unparseable and falls
4277
- // through to `false`, silently disabling the clause it gates. Strip the
4278
- // quantifier and re-evaluate the inner comparison: when the LHS path resolves
4279
- // to a scalar (the framework theater_verdict case) the quantifier is prose
4280
- // emphasis and the scalar comparison is the intended test; when the LHS first
4281
- // segment resolves to an array, apply the predicate existentially (`any`) /
4282
- // universally (`all`) across its elements. The inner clause is only the leaf
4283
- // comparison (the surrounding AND/OR has already been split off above).
3083
+ // Catalog conditions write `any <path> <op> <value>`. An array head applies
3084
+ // the predicate existentially or universally across its elements; a scalar
3085
+ // head means the quantifier was prose and the comparison is the real test.
4284
3086
  const quant = expr.match(/^(any|all)\s+(.+)$/);
4285
3087
  if (quant) {
4286
3088
  const [, kind, inner] = quant;
4287
- // `any head.rest <op|keyword> …` → re-root the predicate at each element of
4288
- // `head` and apply it existentially (`any`) / universally (`all`). The head
4289
- // is the FIRST dotted-path token of the inner clause; what follows it (`==`,
4290
- // `IN [...]`, `contains`, `matches /…/`, `>=`, …) is operator-agnostic — the
4291
- // inner clause is re-evaluated whole against `{ …ctx, [head]: el }`, so every
4292
- // operator the leaf parser already understands works under a quantifier.
4293
- // Earlier this branch only re-rooted clauses whose operator was in the
4294
- // comparison set (`>=|<=|==|=|<|>|!=`); `IN`/`contains`/`matches` were
4295
- // omitted, so e.g. sbom.json's `any matched_cve.attack_class IN
4296
- // ['ai-c2','prompt-injection']` (the sbom→ai-api feeds_into trigger) fell
4297
- // through to a whole-ctx eval where `matched_cve.attack_class` resolved to
4298
- // undefined on the array and the clause was permanently false, while the
4299
- // sibling `== 'kernel-lpe'` quantifiers fired. Requiring a `.`-qualified head
4300
- // keeps the bare-token form (`any <path>`) routed to the non-emptiness branch
4301
- // below.
3089
+ // The clause is re-evaluated WHOLE against `{ …ctx, [head]: el }`, so every
3090
+ // operator the leaf parser understands works under a quantifier. A
3091
+ // `.`-qualified head routes the bare `any <path>` form to the branch below.
4302
3092
  const headMatch = inner.match(/^([A-Za-z_][\w-]*)(?:\.[A-Za-z_][\w-]*)+(?:[\s.[]|$)/);
4303
3093
  if (headMatch) {
4304
3094
  const head = headMatch[1];
@@ -4308,44 +3098,24 @@ function evalCondition(expr, ctx, playbook) {
4308
3098
  return kind === 'all' ? arr.length > 0 && arr.every(test) : arr.some(test);
4309
3099
  }
4310
3100
  }
4311
- // Bare-quantifier non-emptiness form: `any <path>` / `all <path>` with no
4312
- // comparison operator is a quantifier OVER the resolved collection itself —
4313
- // the author's intent is "at least one X exists" (`any`) / "every X is
4314
- // truthy" (`all`), i.e. a non-emptiness / existence test. sbom.json's
4315
- // `any actively_exploited_match …` EU CRA Art.14 (24h) notify_legal
4316
- // escalation is the canonical case. Without this, the inner bare token has
4317
- // no operator, so no comparison branch parses it and it falls through to
4318
- // condition_unparsed → false, silently disabling the escalation even when
4319
- // the array holds entries. Match ONLY a pure dotted-path token (no operator,
4320
- // keyword, bracket, or space); anything with a comparison/keyword still
4321
- // routes to the prose fall-through below so a genuinely malformed inner
4322
- // clause stays observable.
3101
+ // `any <path>` with no operator quantifies over the collection itself. The
3102
+ // match is a pure dotted-path token, so a malformed inner clause falls
3103
+ // through to the diagnostic instead of reading as an existence test.
4323
3104
  if (/^[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*$/.test(inner)) {
4324
3105
  const v = resolvePath(ctx, inner);
4325
3106
  if (Array.isArray(v)) {
4326
3107
  return kind === 'all' ? v.length > 0 && v.every(Boolean) : v.length > 0;
4327
3108
  }
4328
- // Non-array scalar: existence/truthiness (null/undefined/0/'' → false).
4329
3109
  return !!v;
4330
3110
  }
4331
- // Scalar (or unresolved-array) LHS: the quantifier is prose; evaluate the
4332
- // bare inner comparison. If it still doesn't parse it falls through to the
4333
- // condition_unparsed diagnostic below, so a genuinely malformed clause stays
4334
- // observable rather than silently passing.
3111
+ // The quantifier was prose: evaluate the bare inner comparison.
4335
3112
  return evalCondition(inner, ctx, playbook);
4336
3113
  }
4337
3114
 
4338
- // Membership clauses (`contains`/`includes`/`IN [...]`) parse fine but resolve
4339
- // their LHS path to a collection. When that path does NOT exist in ctx (the
4340
- // resolved value is null/undefined — an authoring typo in the LHS token, or a
4341
- // wrong-shape ctx that never populated the collection), the branch returns a
4342
- // silent `false` that disables whatever escalation / feeds_into the clause
4343
- // gates with no signal. The clause PARSED, so condition_unparsed (a
4344
- // parse-failure diagnostic) never fires. Surface a distinct, low-severity
4345
- // condition_path_unresolved so an LHS-path typo is observable — deduped on the
4346
- // condition string like condition_unparsed. A present-but-empty array (or any
4347
- // present value that simply isn't a collection) is a LEGITIMATE false, not an
4348
- // unresolved path, so it does NOT push: only a literally-absent path does.
3115
+ // A clause whose LHS path is absent from ctx parses fine and returns a silent
3116
+ // false, disabling what it gates without firing condition_unparsed. Only a
3117
+ // literally-absent path pushes: a present-but-empty array, or any present
3118
+ // non-collection, is a legitimate false.
4349
3119
  const pushPathUnresolved = () => {
4350
3120
  const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
4351
3121
  : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
@@ -4356,71 +3126,49 @@ function evalCondition(expr, ctx, playbook) {
4356
3126
  }
4357
3127
  };
4358
3128
 
4359
- // "rwep >= 90". The LHS/RHS path tokens admit hyphens because signal and
4360
- // indicator IDs are canonically hyphenated across the catalog (e.g.
4361
- // `no-security-md`, `kver-in-affected-range`); a `\w`-only token silently
4362
- // failed to match every hyphenated condition and fell through to `false`.
3129
+ // "rwep >= 90". Path tokens admit hyphens because signal and indicator ids
3130
+ // are canonically hyphenated across the catalog, and a `\w`-only token
3131
+ // matches none of them — every such condition falls silently to `false`.
4363
3132
  let m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s*(>=|<=|==|=|<|>|!=)\s*(['"]?)([^'"]+)\3$/);
4364
3133
  if (m) {
4365
3134
  const [, lhs, op, quote, rhsRaw] = m;
4366
3135
  const lv = resolvePath(ctx, lhs);
4367
- // A DOTTED (multi-segment) LHS that resolves absent is a suspicious dead
4368
- // condition (authoring typo / wrong-shape ctx) — the same silent-false class
4369
- // the contains/includes/IN branches already surface. Surface it as
4370
- // condition_path_unresolved (observability only; the boolean result is
4371
- // unchanged). Use `== null` (not strict undefined): resolvePath returns
4372
- // `null` for a missing INTERMEDIATE parent and `undefined` for a missing
4373
- // LEAF, so a strict-undefined gate would miss `analyze.classification` when
4374
- // `analyze` itself is absent. Bare single-segment flags
4375
- // (agent_has_filesystem_read, operator-submitted signals) are legitimately
4376
- // absent and do NOT push. A present-but-null flag is a legitimate false:
4377
- // single-segment, so the dot guard already excludes it. pushPathUnresolved
4378
- // dedupes on the condition string and is per-kind capped, so it can't spam.
3136
+ // Diagnostics only; the boolean is unchanged. `== null`, not strict
3137
+ // undefined: resolvePath returns null for a missing intermediate parent and
3138
+ // undefined for a missing leaf. A bare single-segment flag is legitimately
3139
+ // absent and does not push.
4379
3140
  if (lhs.includes('.') && lv == null) pushPathUnresolved();
4380
3141
  let rv = rhsRaw;
4381
3142
  if (quote) {
4382
- // Explicit quoted string literal — keep as-is.
3143
+ // A quoted literal stays as written.
4383
3144
  } else if (rv === 'true') rv = true;
4384
3145
  else if (rv === 'false') rv = false;
4385
3146
  else if (!isNaN(parseFloat(rv)) && /^-?\d+(\.\d+)?$/.test(rv.trim())) rv = parseFloat(rv);
4386
3147
  else if (/^[a-z_][\w.-]*$/i.test(rv.trim())) {
4387
- // Unquoted identifier — treat as a context path. Falls through to the
4388
- // raw string if resolution returns undefined (matches the prior behavior
4389
- // for literals like `theater` that aren't quoted).
3148
+ // An unquoted identifier is a context path, falling back to the raw
3149
+ // string when it does not resolve (an unquoted literal like `theater`).
4390
3150
  const resolved = resolvePath(ctx, rv.trim());
4391
3151
  if (resolved !== undefined && resolved !== null) rv = resolved;
4392
3152
  }
4393
- // Severity is an ordinal word ladder (low < medium < high < critical), not
4394
- // a lexically-ordered string. When both sides are severity words, compare
4395
- // by rank so `severity >= 'high'` includes 'critical' (raw string `>=`
4396
- // would exclude it: 'critical' < 'high' lexicographically).
3153
+ // Severity is an ordinal ladder, not a lexical one: with raw string `>=`,
3154
+ // `severity >= 'high'` EXCLUDES 'critical', because 'critical' < 'high'.
4397
3155
  const SEV = { low: 0, medium: 1, high: 2, critical: 3 };
4398
3156
  const lr = SEV[String(lv).toLowerCase()], rr = SEV[String(rv).toLowerCase()];
4399
3157
  let a = (lr !== undefined && rr !== undefined) ? lr : lv;
4400
3158
  let b = (lr !== undefined && rr !== undefined) ? rr : rv;
4401
3159
  const isOrdering = op === '>=' || op === '<=' || op === '>' || op === '<';
4402
3160
  if (isOrdering && (lr === undefined || rr === undefined)) {
4403
- // Duration literals carry a unit suffix (`24h`, `7d`, `30min`) — the
4404
- // catalog writes ordering comparisons against them (kernel.json's
4405
- // `reboot_window > 24h` raise_severity escalation). The RHS-coercion
4406
- // above only converts a BARE numeric (`/^-?\d+(\.\d+)?$/`), so a unit-
4407
- // suffixed literal stays a string. A numeric LHS then compares against a
4408
- // string RHS (`48 > '24h'` → `48 > NaN` → false: a 48h window silently
4409
- // fails to escalate) and a string LHS compares lexicographically
4410
- // (`'6h' > '24h'` → `'6' > '2'` → true: a 6h window WRONGLY escalates).
4411
- // Normalize both sides to canonical hours when a duration unit appears on
4412
- // either side: a unit-suffixed literal converts by its unit family; a
4413
- // bare number is taken in the same family as the duration it is compared
4414
- // against (hours-equivalent magnitude). The comparison is then numeric.
3161
+ // The catalog writes ordering comparisons against unit-suffixed duration
3162
+ // literals (`reboot_window > 24h`), which the RHS coercion above leaves as
3163
+ // strings: `48 > '24h'` becomes `48 > NaN`, and `'6h' > '24h'` compares as
3164
+ // `'6' > '2'`. Both sides normalize to hours instead.
4415
3165
  const la = parseDurationHours(a), ba = parseDurationHours(b);
4416
3166
  if ((la !== null || ba !== null) && la !== null && ba !== null) {
4417
3167
  a = la; b = ba;
4418
3168
  } else if (
4419
3169
  // Two non-numeric, non-severity, non-duration strings under an ordering
4420
- // operator is a silently-degraded comparison (lexicographic / NaN) — the
4421
- // clause PARSED so condition_unparsed never fires. Surface a distinct
4422
- // condition_type_mismatch so the degraded comparison is observable
4423
- // (the boolean result is unchanged; this is diagnostics only).
3170
+ // operator degrade to a lexicographic or NaN comparison. The clause
3171
+ // parsed, so only condition_type_mismatch makes that observable.
4424
3172
  typeof a !== 'number' && typeof b !== 'number' &&
4425
3173
  !(typeof a === 'string' && /^-?\d+(?:\.\d+)?$/.test(a.trim())) &&
4426
3174
  !(typeof b === 'string' && /^-?\d+(?:\.\d+)?$/.test(b.trim()))
@@ -4444,22 +3192,10 @@ function evalCondition(expr, ctx, playbook) {
4444
3192
  }
4445
3193
  }
4446
3194
 
4447
- // "scope.targets includes named_remote" — `contains` is accepted as a
4448
- // synonym for `includes` (the catalog uses both); hyphenated paths + members
4449
- // are admitted for the same reason as the comparison branch above.
4450
- // The member may be a bare token OR a quoted string (the catalog's
4451
- // `jurisdiction_obligations contains 'EU'` / `contains 'EU/EU CRA Art.14 24h'`
4452
- // forms). When the array holds objects (jurisdiction_obligations are records
4453
- // like `{ jurisdiction:'EU', regulation:'NIS2 Art.21', … }`), membership is
4454
- // field-TARGETED: it matches an identity field of the obligation, not ANY
4455
- // field value. An unscoped `Object.values(el).includes(member)` over-matched
4456
- // — `contains 'EU'` would be satisfied by a non-jurisdiction field that
4457
- // happened to equal 'EU' (e.g. a tag), and `contains 'detect_confirmed'`
4458
- // would match the unrelated `clock_starts` field that holds that exact value
4459
- // in the shipped obligations. Scoping to OBJECT_MEMBERSHIP_FIELDS keeps the
4460
- // intended "the obligation is for jurisdiction X" semantic and prevents a
4461
- // future field collision (or an operator-/agent-supplied obligations array)
4462
- // from forcing a notify_legal escalation via a non-jurisdiction field.
3195
+ // "scope.targets includes named_remote". `contains` is a synonym the catalog
3196
+ // also writes, and the member may be bare or quoted. Against objects,
3197
+ // membership is scoped to OBJECT_MEMBERSHIP_FIELDS: an unscoped
3198
+ // `Object.values(el).includes(member)` over-matches.
4463
3199
  m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+(?:includes|contains)\s+(?:'([^']+)'|"([^"]+)"|([\w-]+))$/);
4464
3200
  if (m) {
4465
3201
  const member = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
@@ -4475,25 +3211,11 @@ function evalCondition(expr, ctx, playbook) {
4475
3211
  );
4476
3212
  }
4477
3213
 
4478
- // "matched_cve.attack_class IN ['kernel-lpe', 'rce']" — membership against a
4479
- // bracketed literal list; members may be quoted or bare. A scalar LHS must be
4480
- // in the list; an array LHS must intersect it. Unhandled before, so every
4481
- // `IN [...]` escalation/feeds_into atom fell through to a silent false.
4482
- // The member list is split quote-aware: a naive `split(',')` is unaware of
4483
- // quotes, so a quoted member that itself contains a comma (`'EU, US'`) would
4484
- // be broken into two members (`EU` and `US`), neither of which equals the
4485
- // author's intended whole member — the clause then evaluated false with no
4486
- // diagnostic (the regex still matched the bracket, so condition_unparsed never
4487
- // fired). splitInMembers walks the list tracking single/double quote state and
4488
- // only splits on commas at quote-depth 0, then strips the surrounding quotes.
4489
- // The CLOSING `]` is located quote-aware too: a `[^\]]*` capture stops at the
4490
- // FIRST `]`, so a quoted member containing a literal `]` (`'a]b'`) truncated
4491
- // the list early and left trailing text the `$` anchor couldn't match — the
4492
- // whole clause then fell through to condition_unparsed for every input. The
4493
- // matching bracket is now found at quote-depth 0 (an unquoted `]` is still the
4494
- // terminator; a `]` inside a quoted member is part of that member). Trailing
4495
- // text after the closing bracket, or an unterminated bracket, still fails to
4496
- // match and surfaces as condition_unparsed.
3214
+ // "matched_cve.attack_class IN ['kernel-lpe', 'rce']" — a scalar LHS must be
3215
+ // in the list, an array LHS must intersect it. Both the member split and the
3216
+ // closing bracket are quote-aware: `split(',')` tears `'EU, US'` into members
3217
+ // that match nothing, and a `[^\]]*` capture truncates `'a]b'`. Either failure
3218
+ // evaluates false with no diagnostic, since the regex still matched.
4497
3219
  m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+IN\s+\[/);
4498
3220
  if (m) {
4499
3221
  const body = sliceInBracketBody(expr.slice(m[0].length));
@@ -4501,44 +3223,29 @@ function evalCondition(expr, ctx, playbook) {
4501
3223
  const members = splitInMembers(body);
4502
3224
  const lv = resolvePath(ctx, m[1]);
4503
3225
  if (Array.isArray(lv)) return lv.some((x) => members.includes(String(x)));
4504
- // lv == null means the LHS path doesn't exist in ctx (typo / wrong-shape) —
4505
- // surface it as condition_path_unresolved so the dead clause is observable,
4506
- // mirroring the contains/includes branch. A present scalar that's simply not
4507
- // in the member list is a legitimate false and pushes nothing.
3226
+ // An absent LHS path is a dead clause, as in the membership branch; a
3227
+ // present scalar simply not in the list is a legitimate false.
4508
3228
  if (lv == null) { pushPathUnresolved(); return false; }
4509
3229
  return members.includes(String(lv));
4510
3230
  }
4511
- // body === null → no quote-depth-0 closing `]` ends the string; fall through
4512
- // to the condition_unparsed diagnostic so the malformed clause stays visible.
3231
+ // No closing bracket at quote-depth 0 — fall through to condition_unparsed.
4513
3232
  }
4514
3233
 
4515
- // "matched_cve.vector matches /regex/" — both delimiters are accepted: the
4516
- // slash form `matches /re/` and the quote form `matches 're'` / `matches "re"`.
4517
- // The catalog authors both (mcp.json's feeds_into uses the quoted form), and a
4518
- // delimiter-specific parser silently disabled whichever form it didn't match —
4519
- // the same class as the hyphenated-token gap above.
3234
+ // "matched_cve.vector matches /regex/". The catalog authors the slash and the
3235
+ // quote form, and a one-delimiter parser silently disables the other.
4520
3236
  m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+matches\s+(?:\/(.+)\/|'([^']+)'|"([^"]+)")$/);
4521
3237
  if (m) {
4522
3238
  const pattern = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
4523
3239
  const val = resolvePath(ctx, m[1]);
4524
3240
  if (typeof val !== 'string') return false;
4525
- // An operator-supplied or playbook-supplied regex with a syntax bug
4526
- // (or pathological backtracking) must NOT crash the engine mid-analyze.
4527
- // Catch construction + test exceptions, return false, and push a
4528
- // structured _regex_eval_error into ctx._runErrors (when present) so
4529
- // analyze() can surface analyze.runtime_errors[] without losing the
4530
- // diagnostic.
3241
+ // A regex with a syntax bug must not crash the engine mid-analyze: the
3242
+ // clause returns false and the failure reaches analyze.runtime_errors[].
4531
3243
  try {
4532
3244
  return new RegExp(pattern, 'i').test(val); // allow:dynamic-regex — pattern comes from an Ed25519-signed catalog playbook condition (/…/ or '…'), so it cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
4533
3245
  } catch (e) {
4534
3246
  const errorRec = { _regex_eval_error: { source: m[1], expr: pattern, message: e && e.message ? String(e.message) : String(e) } };
4535
- // Two sites where ctx may carry an accumulator: runOpts._runErrors
4536
- // (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
4537
- // form; fall back to ctx.
4538
- // Tag with a `kind` so pushRunError can apply per-kind cap + dedupe
4539
- // (same source+expr regex error firing N times per playbook would
4540
- // otherwise spam runtime_errors). The original `_regex_eval_error`
4541
- // payload is preserved for backward compatibility.
3247
+ // The `kind` tag is what lets pushRunError cap and dedupe: one bad regex
3248
+ // otherwise fires once per evaluated element.
4542
3249
  const taggedErr = { kind: 'regex_eval_error', ..._regexErrorPayload(errorRec) };
4543
3250
  const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
4544
3251
  : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
@@ -4552,11 +3259,9 @@ function evalCondition(expr, ctx, playbook) {
4552
3259
  }
4553
3260
  }
4554
3261
 
4555
- // A condition the mini-language can't parse is a DEAD condition — it returns
4556
- // false for every input, silently disabling whatever escalation / feeds_into
4557
- // chain / precondition it gates. Surface it as a runtime_error so a dead
4558
- // condition is observable instead of an invisible `false` (this is how an
4559
- // authoring typo or an unimplemented operator gets caught at runtime).
3262
+ // An unparseable condition is a DEAD condition: false for every input,
3263
+ // silently disabling what it gates. The runtime_error is how an authoring
3264
+ // typo or an unimplemented operator gets caught at all.
4560
3265
  if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
4561
3266
  {
4562
3267
  const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
@@ -4574,15 +3279,9 @@ function resolvePath(obj, dot) {
4574
3279
  return dot.split('.').reduce((acc, k) => acc == null ? null : acc[k], obj);
4575
3280
  }
4576
3281
 
4577
- /**
4578
- * Normalize a duration operand to canonical hours for a numeric comparison.
4579
- * Accepts a unit-suffixed literal (`24h`, `7d`, `2wk`, `30min`) and converts by
4580
- * its unit family, OR a bare number / numeric string (returned as its own
4581
- * magnitude — the catalog writes `reboot_window > 24h` where the LHS resolves to
4582
- * a bare hour count). Returns null for anything that is not a recognized
4583
- * duration or plain number, so the caller can detect that BOTH sides normalized
4584
- * before comparing numerically (and surface a type-mismatch otherwise).
4585
- */
3282
+ // Normalize a duration operand to hours: a unit-suffixed literal by its unit, a
3283
+ // bare number as its own magnitude. Null for anything else, so the caller can
3284
+ // require BOTH sides to normalize before comparing.
4586
3285
  const DURATION_UNIT_HOURS = {
4587
3286
  h: 1, hr: 1, hrs: 1,
4588
3287
  m: 1 / 60, min: 1 / 60,
@@ -4593,7 +3292,6 @@ function parseDurationHours(v) {
4593
3292
  if (typeof v === 'number') return Number.isFinite(v) ? v : null;
4594
3293
  if (typeof v !== 'string') return null;
4595
3294
  const s = v.trim();
4596
- // Bare numeric string (no unit) — take its magnitude as-is.
4597
3295
  if (/^-?\d+(?:\.\d+)?$/.test(s)) return parseFloat(s);
4598
3296
  const m = s.match(/^(\d+(?:\.\d+)?)\s*(h|hr|hrs|d|day|days|wk|w|m|min)$/i);
4599
3297
  if (!m) return null;
@@ -4601,26 +3299,17 @@ function parseDurationHours(v) {
4601
3299
  return mult === undefined ? null : parseFloat(m[1]) * mult;
4602
3300
  }
4603
3301
 
4604
- /**
4605
- * Depth-aware splitter — split `expr` at occurrences of ` <sep> ` (with
4606
- * surrounding spaces) that are at parenthesis depth 0. Returns the (trimmed)
4607
- * sub-expression list. Used by evalCondition so `A OR (B AND C)` splits into
4608
- * [`A`, `(B AND C)`] on OR, instead of naively splitting at the inner AND.
4609
- */
3302
+ // Split `expr` at each ` <sep> ` sitting at parenthesis depth 0, so
3303
+ // `A OR (B AND C)` splits into [`A`, `(B AND C)`] rather than at the inner AND.
4610
3304
  function splitAtTopLevel(expr, sep) {
4611
3305
  const parts = [];
4612
3306
  const needle = ' ' + sep + ' ';
4613
3307
  let depth = 0, buf = '', i = 0, quote = null;
4614
3308
  while (i < expr.length) {
4615
3309
  const ch = expr[i];
4616
- // Inside a quoted string literal, parens and the ` AND `/` OR ` needle are
4617
- // LITERAL text, not boolean structure. A regex member like `matches 'foo('`
4618
- // carries an unbalanced `(` that — counted blindly — would leave depth=1 so
4619
- // a real top-level OR/AND would never split at depth 0 (silently disabling
4620
- // the disjunct/conjunct), and a member like `contains 'EU AND US'` would be
4621
- // torn at the inner ` AND ` as if it were an operator. Track quote state and
4622
- // skip both while inside a quote. An unescaped matching quote closes the
4623
- // literal; `\'`/`\"` stay in. Mirrors splitInMembers' quote-aware walk.
3310
+ // Inside a quoted literal, parens and the needle are text, not structure:
3311
+ // `matches 'foo('` counted blindly leaves depth 1 so no later top-level
3312
+ // OR/AND ever splits, and `contains 'EU AND US'` is torn at its inner ` AND `.
4624
3313
  if (quote) {
4625
3314
  if (ch === '\\' && i + 1 < expr.length) { buf += ch + expr[i + 1]; i += 2; continue; }
4626
3315
  if (ch === quote) quote = null;
@@ -4642,16 +3331,9 @@ function splitAtTopLevel(expr, sep) {
4642
3331
  return parts;
4643
3332
  }
4644
3333
 
4645
- /**
4646
- * Quote-aware splitter for the body of an `IN [...]` member list. Splits on
4647
- * commas that are OUTSIDE any quoted run, then strips the surrounding quotes and
4648
- * trims each member. A naive `.split(',')` is quote-unaware, so a quoted member
4649
- * that contains a comma (`'EU, US'`) would be torn into two members; this walks
4650
- * the string tracking single/double quote state so a comma inside a quoted run
4651
- * is treated as a literal part of that member. Bare (unquoted) members are
4652
- * supported too — the catalog authors both forms (`'ai-c2', 'prompt-injection'`
4653
- * and bare `kernel-lpe`). Empty members (e.g. a trailing comma) are dropped.
4654
- */
3334
+ // Split an `IN [...]` member list on commas outside any quoted run, stripping
3335
+ // the quotes; a comma inside a quoted member (`'EU, US'`) belongs to it. Bare
3336
+ // and quoted members both parse, and empty members are dropped.
4655
3337
  function splitInMembers(listStr) {
4656
3338
  const out = [];
4657
3339
  let buf = '';
@@ -4671,20 +3353,10 @@ function splitInMembers(listStr) {
4671
3353
  return out.filter((s) => s.length);
4672
3354
  }
4673
3355
 
4674
- /**
4675
- * Locate the closing `]` of an `IN [...]` list quote-aware and return the body
4676
- * between the (already-consumed) `[` and that `]`. `rest` is the text after the
4677
- * opening bracket. The terminator is the first `]` encountered at quote-depth 0;
4678
- * a `]` inside a single/double-quoted member (`'a]b'`) is part of the member, not
4679
- * the terminator. A `[^\]]*` regex capture instead stops at the FIRST `]`, so a
4680
- * quoted `]` truncated the list and left trailing text the `$` anchor rejected —
4681
- * the clause then fell through to condition_unparsed for every input.
4682
- *
4683
- * Returns the body string on success, or `null` when the list is malformed:
4684
- * unterminated (no quote-depth-0 `]`) or carrying non-whitespace text after the
4685
- * closing bracket. `null` lets the caller fall through to the condition_unparsed
4686
- * diagnostic so a malformed clause stays observable rather than passing silently.
4687
- */
3356
+ // The body of an `IN [...]` list, given the text after the opening bracket. The
3357
+ // terminator is the first `]` at quote-depth 0; one inside a quoted member
3358
+ // (`'a]b'`) belongs to the member. Null when the list is unterminated or carries
3359
+ // trailing text, so the caller falls through to condition_unparsed.
4688
3360
  function sliceInBracketBody(rest) {
4689
3361
  let quote = null;
4690
3362
  for (let i = 0; i < rest.length; i++) {
@@ -4692,25 +3364,18 @@ function sliceInBracketBody(rest) {
4692
3364
  if (quote) { if (ch === quote) quote = null; continue; }
4693
3365
  if (ch === "'" || ch === '"') { quote = ch; continue; }
4694
3366
  if (ch === ']') {
4695
- // The bracket must be the final structural token: only whitespace may
4696
- // follow. Anything else (e.g. `IN ['a'] AND …` reaching here, or stray
4697
- // trailing text) is malformed for this leaf parser.
3367
+ // Only whitespace may follow: the bracket is the final structural token
3368
+ // for this leaf parser.
4698
3369
  return rest.slice(i + 1).trim() === '' ? rest.slice(0, i) : null;
4699
3370
  }
4700
3371
  }
4701
- return null; // no quote-depth-0 closing bracket → unterminated list
3372
+ return null; // unterminated list
4702
3373
  }
4703
3374
 
4704
- /**
4705
- * Strip a balanced pair of outer parens, if and only if the very first and last
4706
- * characters are matching parens at the same depth boundary. `(A) AND (B)` keeps
4707
- * its parens; `((A AND B))` peels one layer. The depth scan is quote-aware: a
4708
- * paren inside a quoted string literal (e.g. a regex member `matches '(a|b)'` or
4709
- * an unbalanced `matches 'foo('`) is literal text, not grouping structure, so it
4710
- * must not move the depth counter — otherwise a quoted `)` could make the outer
4711
- * pair look unbalanced (skipping a legitimate strip) or an unbalanced quoted `(`
4712
- * could make a non-wrapping pair look outer-spanning (stripping wrongly).
4713
- */
3375
+ // Peel one balanced pair of outer parens, only when the first and last
3376
+ // characters match at the same depth boundary: `((A AND B))` loses a layer,
3377
+ // `(A) AND (B)` keeps both. The depth scan skips quoted literals — counting a
3378
+ // paren in `matches '(a|b)'` misreads which pair, if any, is the outer one.
4714
3379
  function stripOuterParens(expr) {
4715
3380
  while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
4716
3381
  let depth = 0;
@@ -4734,28 +3399,12 @@ function stripOuterParens(expr) {
4734
3399
  return expr;
4735
3400
  }
4736
3401
 
4737
- // Parse an operator-supplied clock_started_at_<event> timestamp into a valid
4738
- // Date, host-timezone-independently. Two failure modes a raw `new Date(s)`
4739
- // silently produces are guarded here:
4740
- //
4741
- // 1. Unparseable value ('not-a-date', '2026-13-99'). new Date() returns an
4742
- // Invalid Date — a truthy object whose getTime() is NaN. Calling
4743
- // .toISOString() on it later throws RangeError, which crashes the
4744
- // deadline math in close() and propagates uncaught out of run(),
4745
- // destroying the entire phase-7 notification/CSAF/deadline output. We
4746
- // return { date: null } so the caller routes to the pending-clock branch
4747
- // instead of crashing.
4748
- //
4749
- // 2. Zone-less ISO ('2026-06-12T10:00:00' or '2026-06-12 10:00:00'). new
4750
- // Date() interprets a designator-less datetime in the HOST timezone, so
4751
- // the published statutory deadline shifts by the host's UTC offset — a
4752
- // 4h DORA window computed on a UTC-7 host lands 7h late. We normalize the
4753
- // space separator to 'T' and append 'Z' so a zone-less value is read as
4754
- // UTC deterministically on every host, and flag assumed_utc so the caller
4755
- // can surface that the zone was assumed.
4756
- //
4757
- // Returns { date, assumed_utc } where date is a valid Date or null, and
4758
- // assumed_utc is true only when a zone-less value was coerced to UTC.
3402
+ // Parse a clock_started_at_<event> timestamp host-timezone-independently,
3403
+ // returning { date, assumed_utc } with a null date on rejection. A bare
3404
+ // `new Date(s)` fails twice here: an unparseable value yields an Invalid Date
3405
+ // whose .toISOString() throws later, crashing the deadline math in close(); and
3406
+ // a zone-less ISO value is read in the HOST timezone, shifting a statutory
3407
+ // deadline by the host's UTC offset. Such a value is coerced to UTC.
4759
3408
  function parseOperatorClock(raw) {
4760
3409
  if (typeof raw !== 'string') {
4761
3410
  const d = new Date(raw);
@@ -4763,10 +3412,8 @@ function parseOperatorClock(raw) {
4763
3412
  }
4764
3413
  const trimmed = raw.trim();
4765
3414
  if (!trimmed) return { date: null, assumed_utc: false };
4766
- // A full ISO datetime whose only missing piece is the zone designator:
4767
- // YYYY-MM-DD, a 'T' or single space separator, HH:MM(:SS(.ms)?)?, and NO
4768
- // trailing 'Z' / [+-]HH:MM offset. Date-only values (YYYY-MM-DD) are already
4769
- // parsed as UTC midnight by spec, so they are left untouched.
3415
+ // A full ISO datetime missing only its zone designator. A date-only value is
3416
+ // already parsed as UTC midnight by spec and is left alone.
4770
3417
  let assumedUtc = false;
4771
3418
  let candidate = trimmed;
4772
3419
  const zonelessDateTime = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
@@ -4779,45 +3426,17 @@ function parseOperatorClock(raw) {
4779
3426
  return { date: d, assumed_utc: assumedUtc };
4780
3427
  }
4781
3428
 
4782
- /**
4783
- * Compute the start instant for a jurisdictional clock event. The agent
4784
- * submits clock_started_at_<event> ISO strings as it progresses through
4785
- * incident-response milestones.
4786
- *
4787
- * Per AGENTS.md Phase 7, the legal contract is that the clock starts
4788
- * from OPERATOR AWARENESS — not from the moment the engine emits a
4789
- * `detected` classification. Auto-stamping Date.now() on detect_confirmed
4790
- * whenever the engine classifies as detected would be incorrect: the
4791
- * operator may not have seen the result yet. Semantics:
4792
- *
4793
- * - If the agent explicitly submits clock_started_at_<event>: use it,
4794
- * after validating it parses and normalizing a zone-less value to UTC.
4795
- * An unparseable value returns null (clock stays pending) and surfaces
4796
- * a runtime error naming the offending key, instead of crashing close().
4797
- * - Otherwise, for 'detect_confirmed' with classification='detected', and
4798
- * for 'analyze_complete' / 'validate_complete' once their phase has run:
4799
- * stamp `now` ONLY if runOpts.operator_consent?.explicit === true
4800
- * (i.e. the operator passed --ack). The analyze/validate phases provably
4801
- * complete inside the same synchronous run before close() computes these
4802
- * clocks, so under --ack there is no operator-awareness gap. Without
4803
- * --ack, return null and the caller (close()) surfaces clock_pending_ack:
4804
- * true on the notification_actions entry so the operator sees that the
4805
- * clock is waiting on acknowledgement.
4806
- * - 'manual' and any other event without an explicit timestamp: return null.
4807
- *
4808
- * `phaseFlags` carries which engine phases completed in this run
4809
- * ({ analyze_complete, validate_complete }) so the auto-stamp for those two
4810
- * events only fires when the named phase actually ran.
4811
- */
3429
+ // The start instant for a jurisdictional clock event. The legal contract is that
3430
+ // the clock starts from OPERATOR AWARENESS, not from the moment the engine emits
3431
+ // a `detected` classification. An explicitly submitted clock_started_at_<event>
3432
+ // wins. detect_confirmed on a detected classification, and analyze_complete /
3433
+ // validate_complete once phaseFlags says their phase ran, auto-stamp `now` ONLY
3434
+ // under runOpts.operator_consent.explicit (--ack). Everything else is null.
4812
3435
  function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassification = null, phaseFlags = {}, frozenEpoch = null) {
4813
- // The agent submits clock_started_at_<event> ISO strings as it progresses.
4814
3436
  const key = `clock_started_at_${eventName}`;
4815
3437
  if (agentSignals && agentSignals[key]) {
4816
3438
  const { date, assumed_utc } = parseOperatorClock(agentSignals[key]);
4817
3439
  if (!date) {
4818
- // A present-but-unparseable timestamp must not crash the close phase via
4819
- // a downstream new Date(NaN).toISOString(). Null routes to the pending
4820
- // branch; the runtime error tells the operator which field was bad.
4821
3440
  if (runOpts && Array.isArray(runOpts._runErrors)) {
4822
3441
  pushRunError(runOpts._runErrors, {
4823
3442
  kind: 'invalid_clock_value',
@@ -4840,26 +3459,15 @@ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassifi
4840
3459
  }
4841
3460
  return date;
4842
3461
  }
4843
- // Auto-stamp gate. Detection is "confirmed" when EITHER the agent submitted
4844
- // detection_classification:'detected' OR the engine itself classified the
4845
- // detect phase as 'detected'. Pre-fix only the agent-submitted signal was
4846
- // honored, so an engine-confirmed detection (indicators fired from
4847
- // signal_overrides without a separate classification submission) never
4848
- // started the regulatory clock — notification deadlines silently stalled.
3462
+ // Confirmed from EITHER a submitted detection_classification or the engine's
3463
+ // own verdict: indicators fired through signal_overrides carry no separate
3464
+ // classification, and their regulatory clock would stall silently.
4849
3465
  const detected = agentSignals?.detection_classification === 'detected'
4850
3466
  || engineClassification === 'detected';
4851
3467
  const ack = !!(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
4852
3468
  if (!ack) return null;
4853
- // detect_confirmed auto-starts on a confirmed detection. analyze_complete /
4854
- // validate_complete auto-start once their engine phase has run in this same
4855
- // pass — the event literally names a phase that completed synchronously
4856
- // before close(), so --ack closes the awareness gap exactly as it does for
4857
- // detect_confirmed.
4858
- // Deterministic bundle mode roots every auto-started clock in the single
4859
- // frozen epoch so two runs over the same evidence emit identical
4860
- // clock_started_at and deadline values; otherwise the clock starts at
4861
- // wall-clock now. Operator-supplied clock timestamps (handled above) are
4862
- // never overridden — they are the explicit input.
3469
+ // Deterministic mode roots every auto-started clock in the frozen epoch, so
3470
+ // two runs over the same evidence agree.
4863
3471
  const autoNow = () => (frozenEpoch ? new Date(frozenEpoch) : new Date());
4864
3472
  if (eventName === 'detect_confirmed' && detected) return autoNow();
4865
3473
  if (eventName === 'analyze_complete' && phaseFlags && phaseFlags.analyze_complete === true) return autoNow();
@@ -4867,24 +3475,17 @@ function computeClockStart(eventName, agentSignals, runOpts = {}, engineClassifi
4867
3475
  return null;
4868
3476
  }
4869
3477
 
3478
+ // The agentSignals lookup key for a precondition expression: the leading path
3479
+ // token, hyphens included, since a hyphenated id is the catalog norm.
4870
3480
  function expressionKey(expr) {
4871
- // For agentSignals precondition lookups — strip operators/values to leave key.
4872
- // Admits hyphens so a hyphenated precondition id (the catalog norm) resolves
4873
- // to the full key instead of being truncated at the first hyphen.
4874
3481
  const m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)/);
4875
3482
  return m ? m[1] : expr;
4876
3483
  }
4877
3484
 
4878
- /**
4879
- * Substitute ${var} placeholders against ctx. F14: pre-fix, missing keys
4880
- * silently re-emitted the literal `${var}` placeholder, so notification
4881
- * drafts could ship to regulators with `${cisa_kev_due_date}` rendered as
4882
- * the raw template — a visible failure that operators wouldn't catch
4883
- * before sending. Now: render as `<MISSING:${var}>` so the failure mode
4884
- * is loud, AND if a tracker array is passed as the third argument,
4885
- * collect the missing keys for caller surfacing as
4886
- * missing_interpolation_vars[].
4887
- */
3485
+ // Substitute ${var} placeholders against ctx. An unresolved key renders as
3486
+ // `<MISSING:key>`, never the literal `${key}` — a notification draft otherwise
3487
+ // reaches a regulator with a raw template in it. missingTracker, when an array,
3488
+ // collects the keys for missing_interpolation_vars[].
4888
3489
  function interpolate(tpl, ctx, missingTracker) {
4889
3490
  if (!tpl || typeof tpl !== 'string') return tpl;
4890
3491
  return tpl.replace(/\$\{(\w+)\}/g, (_, key) => {
@@ -4897,8 +3498,7 @@ function interpolate(tpl, ctx, missingTracker) {
4897
3498
  });
4898
3499
  }
4899
3500
 
4900
- // --- pre-run discovery API: list all directives across all playbooks ---
4901
-
3501
+ // Pre-run discovery: every directive across every playbook.
4902
3502
  function plan(opts = {}) {
4903
3503
  const ids = opts.playbookIds || listPlaybooks();
4904
3504
  return {
@@ -4919,8 +3519,7 @@ function plan(opts = {}) {
4919
3519
  directives: pb.directives.map(d => {
4920
3520
  const overrideDirect = d.phase_overrides?.direct || {};
4921
3521
  const threatContext = overrideDirect.threat_context || baseDirect.threat_context || null;
4922
- // Bug #46: include description by default (not just under --directives).
4923
- // Operators picking a directive need operator-facing prose.
3522
+ // An operator picking a directive needs operator-facing prose.
4924
3523
  const desc = d.description
4925
3524
  || (threatContext ? (threatContext.split(/(?<=[.!?])\s+/)[0] || "").slice(0, 240) : null)
4926
3525
  || pb.domain?.name
@@ -4948,9 +3547,8 @@ module.exports = {
4948
3547
  vexFilterFromDoc,
4949
3548
  normalizeSubmission,
4950
3549
  autoDetectPreconditions,
4951
- // Exported so library-side direct callers (the fallback path the CLI
4952
- // guard cannot reach) can be exercised without spawning a CLI
4953
- // subprocess.
3550
+ // Exported so the library-side path the CLI guard cannot reach is testable
3551
+ // without spawning a subprocess.
4954
3552
  sanitizeOperatorText,
4955
3553
  // internal helpers exposed for tests
4956
3554
  _resolvedPhase: resolvedPhase,
@@ -4967,7 +3565,7 @@ module.exports = {
4967
3565
  _advisoryAuthorityFor: advisoryAuthorityFor,
4968
3566
  _computeClockStart: computeClockStart,
4969
3567
  _worstActiveExploitation: worstActiveExploitation,
4970
- // Re-exported from scoring so parity between the catalog scorer and the
4971
- // runtime evaluator is checkable (and enforced by a test) at the seam.
3568
+ // Re-exported from scoring so a test can enforce parity between the catalog
3569
+ // scorer and the runtime evaluator at the seam.
4972
3570
  _activeExploitationLadder: scoring.ACTIVE_EXPLOITATION_LADDER,
4973
3571
  };