@blamejs/exceptd-skills 0.12.11 → 0.12.13

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.
@@ -72,6 +72,10 @@ function loadPlaybook(playbookId) {
72
72
  return JSON.parse(fs.readFileSync(p, 'utf8'));
73
73
  }
74
74
 
75
+ // E12: per-run playbook cache. Each phase function reads runOpts._playbookCache
76
+ // before falling back to loadPlaybook(). run() sets _playbookCache once at
77
+ // entry so seven phases share one disk read + JSON parse instead of seven.
78
+
75
79
  function findDirective(playbook, directiveId) {
76
80
  const d = playbook.directives.find(x => x.id === directiveId);
77
81
  if (!d) throw new Error(`Directive not found: ${directiveId} in playbook ${playbook._meta.id}`);
@@ -100,9 +104,34 @@ function deepMerge(a, b) {
100
104
 
101
105
  // --- pre-flight: currency + preconditions + mutex ---
102
106
 
107
+ /**
108
+ * Pre-flight gate. Three concerns:
109
+ *
110
+ * 1. Currency. threat_currency_score < 50 hard-blocks unless
111
+ * runOpts.forceStale=true. < 70 emits a warning issue.
112
+ * 2. Preconditions. _meta.preconditions[] entries with on_fail in
113
+ * {halt, warn, skip_phase} are evaluated against
114
+ * runOpts.precondition_checks[id]. Missing values → precondition_unverified
115
+ * issue (plus halt if on_fail=halt). False values → precondition_warn or
116
+ * precondition_skip per on_fail.
117
+ * 3. Mutex. _meta.mutex[] intersect with the in-process active runs set
118
+ * AND with the filesystem lockfile dir blocks the run.
119
+ *
120
+ * E5: when runOpts.strictPreconditions === true, warn-level outcomes
121
+ * (precondition_warn, precondition_unverified with on_fail=warn or
122
+ * skip_phase) are ESCALATED to halts. The function returns ok:false with
123
+ * blocked_by='precondition' and an issues array containing
124
+ * precondition_halt entries. Callers wanting "CI gate: any unverified
125
+ * precondition is a failure" pass strictPreconditions=true.
126
+ *
127
+ * E6: when a precondition with on_fail='skip_phase' fails, the issue carries
128
+ * skip_phase: 'detect' (default) so run() can route to a skipped-phase
129
+ * placeholder rather than executing detect against a missing prerequisite.
130
+ */
103
131
  function preflight(playbook, runOpts = {}) {
104
132
  const issues = [];
105
133
  const meta = playbook._meta;
134
+ const strict = runOpts.strictPreconditions === true;
106
135
 
107
136
  // 1. Currency gate
108
137
  const score = meta.threat_currency_score;
@@ -123,6 +152,18 @@ function preflight(playbook, runOpts = {}) {
123
152
  const submitted = runOpts.precondition_checks?.[pc.id];
124
153
  if (submitted === undefined) {
125
154
  const submission_hint = `Submit precondition_checks in your evidence JSON, e.g. { "precondition_checks": { "${pc.id}": true } }. The runner lifts this into runOpts before the gate evaluates.`;
155
+ if (strict) {
156
+ // E5: strictPreconditions promotes unverified to halt regardless of
157
+ // declared on_fail.
158
+ issues.push({ kind: 'precondition_halt', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint, escalated_from: 'precondition_unverified' });
159
+ return {
160
+ ok: false,
161
+ blocked_by: 'precondition',
162
+ reason: `Precondition ${pc.id} (${pc.check}) not verified by host AI; strict-preconditions enabled.`,
163
+ remediation: submission_hint,
164
+ issues
165
+ };
166
+ }
126
167
  issues.push({ kind: 'precondition_unverified', id: pc.id, check: pc.check, on_fail: pc.on_fail, submission_hint });
127
168
  if (pc.on_fail === 'halt') {
128
169
  return {
@@ -139,7 +180,25 @@ function preflight(playbook, runOpts = {}) {
139
180
  if (pc.on_fail === 'halt') {
140
181
  return { ok: false, blocked_by: 'precondition', reason: `Precondition ${pc.id} failed: ${pc.description}`, issues };
141
182
  }
142
- issues.push({ kind: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn', id: pc.id, message: pc.description });
183
+ if (strict) {
184
+ // E5: warn-level + skip_phase outcomes escalate to halt under strict.
185
+ issues.push({ kind: 'precondition_halt', id: pc.id, message: pc.description, escalated_from: pc.on_fail === 'skip_phase' ? 'precondition_skip' : 'precondition_warn' });
186
+ return {
187
+ ok: false,
188
+ blocked_by: 'precondition',
189
+ reason: `Precondition ${pc.id} (${pc.check}) failed; strict-preconditions enabled.`,
190
+ issues
191
+ };
192
+ }
193
+ if (pc.on_fail === 'skip_phase') {
194
+ // E6: emit a skip_phase field so run() can route to a skipped-phase
195
+ // placeholder. Default target phase is 'detect' (the most common
196
+ // skip target — preconditions typically gate host-side detection).
197
+ // Playbooks may override via pc.skip_phase.
198
+ issues.push({ kind: 'precondition_skip', id: pc.id, message: pc.description, skip_phase: pc.skip_phase || 'detect' });
199
+ } else {
200
+ issues.push({ kind: 'precondition_warn', id: pc.id, message: pc.description });
201
+ }
143
202
  }
144
203
  }
145
204
 
@@ -214,7 +273,7 @@ function pidAlive(pid) {
214
273
  * fingerprints, framework gap summary, and skills to preload.
215
274
  */
216
275
  function govern(playbookId, directiveId, runOpts = {}) {
217
- const playbook = loadPlaybook(playbookId);
276
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
218
277
  const g = resolvedPhase(playbook, directiveId, 'govern');
219
278
  return {
220
279
  phase: 'govern',
@@ -238,8 +297,8 @@ function govern(playbookId, directiveId, runOpts = {}) {
238
297
 
239
298
  // --- phase 2: direct ---
240
299
 
241
- function direct(playbookId, directiveId) {
242
- const playbook = loadPlaybook(playbookId);
300
+ function direct(playbookId, directiveId, runOpts = {}) {
301
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
243
302
  const d = resolvedPhase(playbook, directiveId, 'direct');
244
303
  return {
245
304
  phase: 'direct',
@@ -256,7 +315,7 @@ function direct(playbookId, directiveId) {
256
315
  // --- phase 3: look (engine emits, agent executes) ---
257
316
 
258
317
  function look(playbookId, directiveId, runOpts = {}) {
259
- const playbook = loadPlaybook(playbookId);
318
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
260
319
  const l = resolvedPhase(playbook, directiveId, 'look');
261
320
  const airGap = !!playbook._meta.air_gap_mode || !!runOpts.airGap;
262
321
  return {
@@ -303,8 +362,8 @@ function look(playbookId, directiveId, runOpts = {}) {
303
362
  * and (optionally) `signal_overrides` as { indicator_id: 'hit'|'miss'|'inconclusive' } to
304
363
  * record an indicator outcome the agent computed using its own pattern matching.
305
364
  */
306
- function detect(playbookId, directiveId, agentSubmission = {}) {
307
- const playbook = loadPlaybook(playbookId);
365
+ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
366
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
308
367
  const det = resolvedPhase(playbook, directiveId, 'detect');
309
368
  const artifacts = agentSubmission.artifacts || {};
310
369
  const overrides = agentSubmission.signal_overrides || {};
@@ -323,24 +382,61 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
323
382
  return null; // truly unknown — fall through
324
383
  };
325
384
 
385
+ // E1: per-indicator FP-check attestation map. Operators submit
386
+ // signal_overrides: { '<indicator-id>__fp_checks': { '<fp-check-name>': true } }
387
+ // to declare which named false_positive_checks_required[] entries on the
388
+ // indicator have been satisfied. An unverified FP check downgrades the
389
+ // verdict from 'hit' to 'inconclusive' and surfaces fp_checks_unsatisfied
390
+ // on the per-indicator result. See AGENTS.md Hard Rule #6 (compliance
391
+ // theater) and AGENTS.md §"detect (AI)" — a `hit` without its FP checks
392
+ // is not yet a `detected` classification.
326
393
  const indicatorResults = (det.indicators || []).map(ind => {
327
394
  const rawOverride = overrides[ind.id];
328
395
  const override = canonicalize(rawOverride);
329
396
  let verdict;
397
+ let fpChecksUnsatisfied = null;
330
398
  if (override === 'hit' || override === 'miss' || override === 'inconclusive') {
331
399
  verdict = override;
400
+ // E1: gate 'hit' verdict on per-indicator false_positive_checks_required
401
+ // satisfaction. The FP-check attestation arrives as a sibling key
402
+ // '<id>__fp_checks' in signal_overrides; default behavior (no
403
+ // attestation) treats every required FP check as UNSATISFIED.
404
+ if (verdict === 'hit' && Array.isArray(ind.false_positive_checks_required) && ind.false_positive_checks_required.length) {
405
+ const attestation = overrides[`${ind.id}__fp_checks`];
406
+ const att = (attestation && typeof attestation === 'object') ? attestation : {};
407
+ const unsatisfied = ind.false_positive_checks_required.filter(fpName => {
408
+ // Match either by exact name string OR by indexed key '0', '1', ...
409
+ // because false_positive_checks_required entries are free-text
410
+ // strings, not ids. Operators may attest either by the literal
411
+ // string or by index. Default: unsatisfied.
412
+ if (att[fpName] === true) return false;
413
+ const idx = ind.false_positive_checks_required.indexOf(fpName);
414
+ if (idx !== -1 && att[String(idx)] === true) return false;
415
+ return true;
416
+ });
417
+ if (unsatisfied.length > 0) {
418
+ verdict = 'inconclusive';
419
+ fpChecksUnsatisfied = unsatisfied;
420
+ }
421
+ }
332
422
  } else {
333
423
  // Without an explicit override, treat any captured artifact as evidence
334
- // the indicator could be evaluated. Mark inconclusive if no related
335
- // artifact was captured engine doesn't pattern-match raw artifact
336
- // content; the host AI is responsible for that.
424
+ // the indicator could be evaluated. Mark inconclusive if any artifact
425
+ // was captured (engine doesn't pattern-match raw artifact content; the
426
+ // host AI is responsible for that). With NO captured artifacts, this is
427
+ // a clean empty submission — emit 'miss' so the run can reach
428
+ // classification:'not_detected' rather than getting stuck inconclusive.
429
+ // E2: pre-fix both arms emitted 'inconclusive', so a clean empty run
430
+ // could never reach not_detected and theater_verdict stayed
431
+ // 'pending_agent_run' forever.
337
432
  const anyCaptured = Object.values(artifacts).some(a => a && a.captured);
338
- verdict = anyCaptured ? 'inconclusive' : 'inconclusive';
433
+ verdict = anyCaptured ? 'inconclusive' : 'miss';
339
434
  }
340
435
  return {
341
436
  id: ind.id, type: ind.type, confidence: ind.confidence,
342
437
  deterministic: ind.deterministic, atlas_ref: ind.atlas_ref || null,
343
- attack_ref: ind.attack_ref || null, verdict
438
+ attack_ref: ind.attack_ref || null, verdict,
439
+ ...(fpChecksUnsatisfied ? { fp_checks_unsatisfied: fpChecksUnsatisfied } : {})
344
440
  };
345
441
  });
346
442
 
@@ -402,7 +498,11 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
402
498
  })),
403
499
  indicators_evaluated_count: indicatorResults.length,
404
500
  classification_override_applied: validOverrides.has(override) ? (override === 'clean' ? 'not_detected' : override) : null,
405
- submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty')
501
+ submission_shape_seen: agentSubmission._original_shape || (agentSubmission.artifacts ? 'nested (v0.10.x)' : 'empty'),
502
+ // E9: pass through any flat-shape observation collisions detected at
503
+ // normalize time so analyze() can publish them under
504
+ // analyze.signal_origins_with_collisions.
505
+ _signal_origins_collisions: Array.isArray(agentSubmission._signal_origins_collisions) ? agentSubmission._signal_origins_collisions.slice() : []
406
506
  };
407
507
  }
408
508
 
@@ -413,8 +513,8 @@ function detect(playbookId, directiveId, agentSubmission = {}) {
413
513
  * mapping + escalation evaluation. Inputs are the detect result + any
414
514
  * agent-submitted signal_values (e.g. blast_radius classification).
415
515
  */
416
- function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
417
- const playbook = loadPlaybook(playbookId);
516
+ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOpts = {}) {
517
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
418
518
  const an = resolvedPhase(playbook, directiveId, 'analyze');
419
519
  const directive = findDirective(playbook, directiveId);
420
520
 
@@ -572,8 +672,10 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
572
672
 
573
673
  // escalation criteria
574
674
  const escalations = [];
675
+ const runtimeErrors = []; // E3: collect regex-eval errors during analyze
676
+ const evalCtxRoot = { _runErrors: runOpts._runErrors || runtimeErrors };
575
677
  for (const ec of an.escalation_criteria || []) {
576
- if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals }, playbook)) {
678
+ if (evalCondition(ec.condition, { rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, ...agentSignals, ...evalCtxRoot }, playbook)) {
577
679
  escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
578
680
  }
579
681
  }
@@ -625,7 +727,18 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}) {
625
727
  note: vexDropped.length
626
728
  ? `${vexDropped.length} CVE(s) dropped from analyze because the operator-supplied VEX statement marks them not_affected / resolved / false_positive. They remain in cve-catalog.json; the disposition lives in the VEX file.`
627
729
  : "VEX filter supplied; zero matches dropped (no CVEs in domain.cve_refs matched the VEX not-affected set)."
628
- } : null
730
+ } : null,
731
+ // E3: regex-eval failures surfaced here so operators can see WHICH
732
+ // condition expression crashed without the runner dying. Only present
733
+ // when at least one evalCondition() call hit a regex exception during
734
+ // this analyze pass; runOpts._runErrors is the same accumulator
735
+ // populated by run() across all phases, so callers reading this field
736
+ // see every regex problem in the run.
737
+ runtime_errors: (runOpts._runErrors && runOpts._runErrors.length) ? runOpts._runErrors.slice() : (runtimeErrors.length ? runtimeErrors.slice() : []),
738
+ // E9: collisions when two flat-shape observations targeted the same
739
+ // indicator id. Empty when there were no collisions or no flat-shape
740
+ // observations submitted.
741
+ 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() : [])
629
742
  };
630
743
  }
631
744
 
@@ -657,8 +770,11 @@ function vexFilterFromDoc(doc) {
657
770
 
658
771
  // --- phase 6: validate ---
659
772
 
660
- function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
661
- const playbook = loadPlaybook(playbookId);
773
+ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
774
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
775
+ // E3: surface evalCondition regex errors raised here into the same
776
+ // run-wide accumulator that analyze() reads.
777
+ const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
662
778
  const v = resolvedPhase(playbook, directiveId, 'validate');
663
779
 
664
780
  // Pick the highest-priority remediation_path whose preconditions are all
@@ -669,7 +785,7 @@ function validate(playbookId, directiveId, analyzeResult, agentSignals = {}) {
669
785
  for (const p of paths) {
670
786
  const pcResult = (p.preconditions || []).map(expr => ({
671
787
  expr,
672
- satisfied: evalCondition(expr, agentSignals, playbook),
788
+ satisfied: evalCondition(expr, evalCtx, playbook),
673
789
  submitted: agentSignals[expressionKey(expr)] !== undefined
674
790
  }));
675
791
  const allSatisfied = pcResult.every(x => x.satisfied);
@@ -723,7 +839,7 @@ function computeRegressionNextRun(triggers) {
723
839
  * - feeds_into chaining suggestions
724
840
  */
725
841
  function close(playbookId, directiveId, analyzeResult, validateResult, agentSignals = {}, runOpts = {}) {
726
- const playbook = loadPlaybook(playbookId);
842
+ const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
727
843
  const c = resolvedPhase(playbook, directiveId, 'close');
728
844
  const g = resolvedPhase(playbook, directiveId, 'govern');
729
845
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
@@ -741,7 +857,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
741
857
  const obligation = (g.jurisdiction_obligations || []).find(o =>
742
858
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
743
859
  );
744
- const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals) : null;
860
+ // E7: thread runOpts through so computeClockStart can check
861
+ // operator_consent.explicit before auto-stamping detect_confirmed.
862
+ const clockStart = obligation ? computeClockStart(obligation.clock_starts, agentSignals, runOpts) : null;
863
+ // E7: when the clock event is detect_confirmed AND the classification
864
+ // matched AND the operator did NOT pass --ack, surface clock_pending_ack
865
+ // so the notification record is visibly waiting on acknowledgement.
866
+ const clockPendingAck = !clockStart
867
+ && obligation?.clock_starts === 'detect_confirmed'
868
+ && agentSignals?.detection_classification === 'detected'
869
+ && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
745
870
  const deadline = obligation && clockStart
746
871
  ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
747
872
  : 'pending_clock_start_event';
@@ -756,6 +881,7 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
756
881
  window_hours: obligation?.window_hours ?? null,
757
882
  clock_start_event: obligation?.clock_starts || null,
758
883
  clock_started_at: clockStart?.toISOString() || null,
884
+ ...(clockPendingAck ? { clock_pending_ack: true } : {}),
759
885
  deadline,
760
886
  // Alias matching compliance-team vocabulary.
761
887
  notification_deadline: deadline,
@@ -769,7 +895,8 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
769
895
  // exception_generation — evaluate trigger.
770
896
  let exception = null;
771
897
  if (c.exception_generation) {
772
- const triggered = evalCondition(c.exception_generation.trigger_condition, agentSignals, playbook);
898
+ const closeEvalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
899
+ const triggered = evalCondition(c.exception_generation.trigger_condition, closeEvalCtx, playbook);
773
900
  if (triggered) {
774
901
  const t = c.exception_generation.exception_template;
775
902
  exception = {
@@ -803,9 +930,9 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
803
930
  contents: c.evidence_package.contents || [],
804
931
  destination: c.evidence_package.destination || 'local_only',
805
932
  signed: c.evidence_package.signed !== false,
806
- bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals),
933
+ bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals, sessionId),
807
934
  bundles_by_format: extraFormats.length ? Object.fromEntries(
808
- [primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals)])
935
+ [primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals, sessionId)])
809
936
  ) : null,
810
937
  } : null;
811
938
 
@@ -847,7 +974,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
847
974
  analyze: analyzeResult,
848
975
  validate: validateResult,
849
976
  finding: analyzeFindingShape(analyzeResult),
850
- ...agentSignals
977
+ ...agentSignals,
978
+ // E3: surface evalCondition regex failures from the feeds_into chain
979
+ // into the same accumulator. Without this the regex failure happens but
980
+ // analyze.runtime_errors[] never sees it.
981
+ ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {})
851
982
  };
852
983
  const feeds = (playbook._meta.feeds_into || [])
853
984
  .filter(f => evalCondition(f.condition, feedsCtx, playbook))
@@ -873,12 +1004,34 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
873
1004
  };
874
1005
  }
875
1006
 
1007
+ // E8: severity ladder for active_exploitation. The worst-of reduction lets
1008
+ // analyzeFindingShape report the most-exploited CVE in the matched set, not
1009
+ // the first-encountered one. Higher index = worse.
1010
+ const ACTIVE_EXPLOITATION_RANK = { none: 0, unknown: 1, suspected: 2, confirmed: 3 };
1011
+
1012
+ function worstActiveExploitation(matchedCves) {
1013
+ let worst = null;
1014
+ let worstRank = -1;
1015
+ for (const c of (matchedCves || [])) {
1016
+ const v = c && c.active_exploitation;
1017
+ if (!v) continue;
1018
+ const rank = ACTIVE_EXPLOITATION_RANK[v] ?? -1;
1019
+ if (rank > worstRank) { worst = v; worstRank = rank; }
1020
+ }
1021
+ return worst || 'unknown';
1022
+ }
1023
+
876
1024
  function analyzeFindingShape(a) {
877
1025
  return {
878
1026
  matched_cve_ids: (a.matched_cves || []).map(c => c.cve_id).join(', '),
879
1027
  matched_cve_count: (a.matched_cves || []).length,
880
1028
  kev_listed_count: (a.matched_cves || []).filter(c => c.cisa_kev).length,
881
- active_exploitation: (a.matched_cves || []).find(c => c.active_exploitation)?.active_exploitation || 'unknown',
1029
+ // E8: previously this used .find() which returned the first matched CVE
1030
+ // with a truthy active_exploitation. With two CVEs where #1 is
1031
+ // 'suspected' and #2 is 'confirmed', operators saw 'suspected' on
1032
+ // notification drafts — under-stating the threat. Now reduce to the
1033
+ // worst rank across all matched CVEs.
1034
+ active_exploitation: worstActiveExploitation(a.matched_cves),
882
1035
  rwep_adjusted: a.rwep?.adjusted ?? 0,
883
1036
  rwep_base: a.rwep?.base ?? 0,
884
1037
  blast_radius_score: a.blast_radius_score ?? 0,
@@ -887,35 +1040,94 @@ function analyzeFindingShape(a) {
887
1040
  };
888
1041
  }
889
1042
 
890
- function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals) {
1043
+ // Slugify a string into a URN-safe segment ([a-z0-9_-]+ per RFC 8141 NSS).
1044
+ // Empty input → 'unknown' so we never emit zero-length segments.
1045
+ function urnSlug(s) {
1046
+ if (s == null) return 'unknown';
1047
+ const slug = String(s)
1048
+ .toLowerCase()
1049
+ .replace(/[^a-z0-9_-]+/g, '-')
1050
+ .replace(/^-+|-+$/g, '');
1051
+ return slug.length ? slug : 'unknown';
1052
+ }
1053
+
1054
+ // Build the canonical product binding shared by CSAF + OpenVEX. CSAF's
1055
+ // product_tree must declare every product referenced from
1056
+ // vulnerabilities[].product_status; OpenVEX statements MUST carry a
1057
+ // `products` array per spec §4.3.
1058
+ function buildProductBinding(playbook, sessionId) {
1059
+ const playbookSlug = urnSlug(playbook._meta.id);
1060
+ const sessionSlug = urnSlug(sessionId || 'session');
1061
+ const productId = `exceptd-target-${playbookSlug}-${sessionSlug}`;
1062
+ const productPurl = `pkg:exceptd/scan/${sessionSlug}/${playbookSlug}`;
1063
+ return {
1064
+ productId,
1065
+ productPurl,
1066
+ productName: playbook.domain?.name || playbook._meta.id,
1067
+ };
1068
+ }
1069
+
1070
+ // Best-effort SARIF location list for an indicator hit. Indicator records
1071
+ // don't carry a direct artifact reference; we fall back to the playbook's
1072
+ // look-phase artifact source paths (the inspected files/processes). GitHub
1073
+ // Code Scanning hides results without `artifactLocation.uri`, so we
1074
+ // surface at least one candidate when any is known. Returns null when no
1075
+ // candidate exists — caller MUST omit `locations` rather than emit empty.
1076
+ function sarifLocationsForIndicator(playbook, indicator) {
1077
+ const arts = (playbook.phases?.look?.artifacts) || [];
1078
+ const candidates = arts
1079
+ .map(a => a && (a.source || a.air_gap_alternative))
1080
+ .filter(Boolean)
1081
+ .map(src => String(src).split(/\s+(?:AND|OR)\s+/i)[0].trim())
1082
+ .filter(src => src && !/^https?:/i.test(src));
1083
+ if (!candidates.length) return null;
1084
+ return [{ physicalLocation: { artifactLocation: { uri: candidates[0] } } }];
1085
+ }
1086
+
1087
+ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals, sessionId) {
1088
+ const playbookSlug = urnSlug(playbook._meta.id);
1089
+ const { productId, productPurl, productName } = buildProductBinding(playbook, sessionId);
1090
+
891
1091
  // CSAF-2.0 shape. v0.11.5 (#82): include vulnerabilities for both matched
892
1092
  // catalogue CVEs AND fired indicators (treated as advisory pseudo-CVEs
893
1093
  // under `exceptd:` namespace), so playbooks without catalogue CVEs still
894
1094
  // emit a non-empty bundle.
1095
+ //
1096
+ // v0.12.12 (B5): emit a product_tree so csaf_security_advisory documents
1097
+ // pass NVD/ENISA/Red Hat dashboard validation. Every vulnerability
1098
+ // entry references the product via product_status so the binding is
1099
+ // real, not cosmetic.
895
1100
  if (format === 'csaf-2.0') {
896
1101
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
897
- const cveVulns = analyze.matched_cves.map(c => ({
898
- cve: c.cve_id,
899
- scores: [{ products: [], cvss_v3: { base_score: c.cvss_score || 0 } }],
900
- threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
901
- remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
902
- }));
1102
+ const fullProductNames = [{
1103
+ product_id: productId,
1104
+ name: productName,
1105
+ product_identification_helper: { purl: productPurl }
1106
+ }];
1107
+ const cveVulns = analyze.matched_cves.map(c => {
1108
+ const isAffected = c.live_patch_available !== true;
1109
+ return {
1110
+ cve: c.cve_id,
1111
+ scores: [{ products: [productId], cvss_v3: { base_score: c.cvss_score || 0 } }],
1112
+ threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
1113
+ remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.', product_ids: [productId] }],
1114
+ product_status: isAffected ? { known_affected: [productId] } : { fixed: [productId] }
1115
+ };
1116
+ });
903
1117
  const indicatorVulns = indicatorHits.map(i => ({
904
- // Pseudo-CVE id for indicator findings (CSAF requires `cve` or `ids`).
905
1118
  ids: [{ system_name: 'exceptd-indicator', text: `${playbook._meta.id}:${i.id}` }],
906
1119
  notes: [{ category: 'description', text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}) in playbook ${playbook._meta.id}.` }],
907
- remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.` }],
1120
+ remediations: [{ category: 'mitigation', details: validate.selected_remediation?.description || `Consult playbook brief: exceptd brief ${playbook._meta.id}.`, product_ids: [productId] }],
1121
+ product_status: { known_affected: [productId] }
908
1122
  }));
909
- // v0.11.6 (#91): framework_gap_mapping → CSAF vulnerabilities. Each gap
910
- // becomes a vulnerability keyed by the framework + control, with the
911
- // gap text as the description and the required_control as the remediation.
912
1123
  const gapVulns = (analyze.framework_gap_mapping || []).map((g, idx) => ({
913
1124
  ids: [{ system_name: 'exceptd-framework-gap', text: `${g.framework}:${g.claimed_control || `gap-${idx}`}` }],
914
1125
  notes: [
915
1126
  { category: 'description', text: g.actual_gap || `Framework gap in ${g.framework} ${g.claimed_control || ''}` },
916
1127
  { category: 'general', text: g.claimed_control ? `Claimed control: ${g.claimed_control}` : null },
917
1128
  ].filter(n => n.text),
918
- remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control }] : [],
1129
+ remediations: g.required_control ? [{ category: 'mitigation', details: g.required_control, product_ids: [productId] }] : [],
1130
+ product_status: { under_investigation: [productId] }
919
1131
  }));
920
1132
  const now = new Date().toISOString();
921
1133
  return {
@@ -929,13 +1141,11 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
929
1141
  status: 'final',
930
1142
  version: playbook._meta.version,
931
1143
  initial_release_date: now,
932
- // v0.11.6 (#92): CSAF 2.0 §3.2.1.12 requires current_release_date
933
- // non-null. Pre-0.11.6 we only set initial_release_date and
934
- // downstream validators rejected the bundle.
935
1144
  current_release_date: now,
936
1145
  revision_history: [{ number: '1', date: now, summary: 'Initial finding emission' }]
937
1146
  }
938
1147
  },
1148
+ product_tree: { full_product_names: fullProductNames },
939
1149
  vulnerabilities: [...cveVulns, ...indicatorVulns, ...gapVulns],
940
1150
  exceptd_extension: {
941
1151
  classification: analyze._detect_classification,
@@ -953,36 +1163,54 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
953
1163
  // SARIF 2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
954
1164
  // / most static-analysis tooling.
955
1165
  //
956
- // v0.11.5 (#82): emit results from BOTH matched_cves AND fired indicators.
957
- // Pre-0.11.5 we emitted only matched_cves, which produced an empty bundle
958
- // for playbooks like crypto-codebase / library-author whose domain.cve_refs
959
- // is intentionally empty (the playbook checks process/posture, not catalog
960
- // CVEs). Indicators that fire (verdict: hit) and framework gaps are now
961
- // first-class SARIF results — a clean run still emits a usable bundle.
1166
+ // v0.12.12 (B6): thread artifact source paths through to
1167
+ // result.locations[].physicalLocation.artifactLocation.uri. GitHub Code
1168
+ // Scanning hides results without populated locations, so the heuristic
1169
+ // ensures clean playbook runs still surface findings in the alerts UI.
1170
+ // v0.12.12 (B7): omit null property-bag keys so SARIF viewers don't
1171
+ // render empty fields.
962
1172
  if (format === 'sarif' || format === 'sarif-2.1.0') {
1173
+ const stripNulls = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
963
1174
  const cveResults = analyze.matched_cves.map(c => ({
964
1175
  ruleId: c.cve_id,
965
1176
  level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
966
1177
  message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
967
- properties: {
1178
+ properties: stripNulls({
968
1179
  kind: 'cve_match',
969
- rwep: c.rwep, cisa_kev: c.cisa_kev, cisa_kev_due_date: c.cisa_kev_due_date,
970
- active_exploitation: c.active_exploitation, ai_discovered: c.ai_discovered,
1180
+ rwep: c.rwep,
1181
+ cisa_kev: c.cisa_kev,
1182
+ cisa_kev_due_date: c.cisa_kev_due_date ?? null,
1183
+ active_exploitation: c.active_exploitation ?? null,
1184
+ ai_discovered: c.ai_discovered ?? null,
971
1185
  blast_radius_score: analyze.blast_radius_score,
972
- }
1186
+ }),
973
1187
  }));
974
1188
  const indicatorHits = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit');
975
- const indicatorResults = indicatorHits.map(i => ({
976
- ruleId: i.id,
977
- level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
978
- message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
979
- properties: { kind: 'indicator_hit', confidence: i.confidence, deterministic: i.deterministic, atlas_ref: i.atlas_ref, attack_ref: i.attack_ref },
980
- }));
1189
+ const indicatorResults = indicatorHits.map(i => {
1190
+ const locs = sarifLocationsForIndicator(playbook, i);
1191
+ const result = {
1192
+ ruleId: i.id,
1193
+ level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note'),
1194
+ message: { text: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? ' / deterministic' : ''}). Playbook: ${playbook._meta.id}.` },
1195
+ properties: stripNulls({
1196
+ kind: 'indicator_hit',
1197
+ confidence: i.confidence,
1198
+ deterministic: i.deterministic,
1199
+ atlas_ref: i.atlas_ref,
1200
+ attack_ref: i.attack_ref,
1201
+ }),
1202
+ };
1203
+ if (locs) result.locations = locs;
1204
+ return result;
1205
+ });
981
1206
  const gapResults = (analyze.framework_gap_mapping || []).map((g, idx) => ({
982
1207
  ruleId: `framework-gap-${idx}`,
1208
+ // Framework gaps are control-design observations, not vulnerabilities —
1209
+ // SARIF §3.27.9 `kind: informational` routes them appropriately.
1210
+ kind: 'informational',
983
1211
  level: 'note',
984
1212
  message: { text: `${g.framework}: ${g.claimed_control} — ${g.actual_gap}${g.required_control ? '. Required: ' + g.required_control : ''}` },
985
- properties: { kind: 'framework_gap', framework: g.framework, control: g.claimed_control },
1213
+ properties: stripNulls({ kind: 'framework_gap', framework: g.framework, control: g.claimed_control }),
986
1214
  }));
987
1215
  const cveRules = analyze.matched_cves.map(c => ({
988
1216
  id: c.cve_id, shortDescription: { text: c.cve_id },
@@ -995,11 +1223,6 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
995
1223
  fullDescription: { text: `Indicator from playbook ${playbook._meta.id}. Type: ${i.type}. Confidence: ${i.confidence}.` },
996
1224
  defaultConfiguration: { level: i.deterministic ? 'error' : (i.confidence === 'high' ? 'warning' : 'note') },
997
1225
  }));
998
- // v0.11.6 (#93): SARIF spec §3.27.3 — every referenced ruleId SHOULD have
999
- // a corresponding rule definition in tool.driver.rules. Pre-0.11.6 we
1000
- // referenced framework-gap-N ids without defining them; GitHub Code
1001
- // Scanning + VS Code SARIF Viewer + Azure DevOps would warn or fail to
1002
- // display rule context. Now we emit one rule per framework gap.
1003
1226
  const gapRules = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1004
1227
  id: `framework-gap-${idx}`,
1005
1228
  shortDescription: { text: `${g.framework}: ${g.claimed_control || `gap-${idx}`}` },
@@ -1025,42 +1248,86 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
1025
1248
  };
1026
1249
  }
1027
1250
 
1028
- // OpenVEX 0.2.0 — supply-chain VEX statements. v0.11.5 (#82): also include
1029
- // statements derived from fired indicators (treated as advisory findings)
1030
- // so playbooks with empty cve_refs still emit a meaningful bundle.
1251
+ // OpenVEX 0.2.0 — supply-chain VEX statements.
1252
+ //
1253
+ // v0.12.12 (B1-B4): correctness sweep against the OpenVEX 0.2.0 spec.
1254
+ // - B1: every statement now carries a `products` array (spec MUST).
1255
+ // - B2: `status` derives from the verdict + confidence rather than being
1256
+ // hard-coded to `under_investigation`. Hits emit `affected` with
1257
+ // an action_statement; misses emit `not_affected` with a
1258
+ // justification; inconclusive findings keep `under_investigation`.
1259
+ // - B3: framework gaps are control-design observations, not
1260
+ // vulnerabilities — they are removed from the VEX emit path. They
1261
+ // remain in CSAF (informational notes) and SARIF (kind:
1262
+ // informational rules).
1263
+ // - B4: vulnerability `@id` values switch to the registered URN namespace
1264
+ // `urn:exceptd:indicator:<playbook>:<indicator-id>` (RFC 8141) so
1265
+ // they pass IRI validation in downstream VEX consumers.
1031
1266
  if (format === 'openvex' || format === 'openvex-0.2.0') {
1032
1267
  const issued = new Date().toISOString();
1033
- const cveStatements = analyze.matched_cves.map(c => ({
1034
- vulnerability: { '@id': c.cve_id, name: c.cve_id },
1035
- status: c.active_exploitation === 'confirmed' ? 'under_investigation' : (c.live_patch_available ? 'fixed' : 'affected'),
1036
- timestamp: issued,
1037
- action_statement: validate.selected_remediation?.description || null,
1038
- impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`
1039
- }));
1040
- const indicatorStatements = (analyze._detect_indicators || []).filter(i => i.verdict === 'hit').map(i => ({
1041
- vulnerability: { '@id': `exceptd:${playbook._meta.id}:${i.id}`, name: i.id },
1042
- status: 'under_investigation',
1043
- timestamp: issued,
1044
- action_statement: validate.selected_remediation?.description || `Run \`exceptd brief ${playbook._meta.id}\` for context.`,
1045
- impact_statement: `Indicator ${i.id} fired (${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
1046
- }));
1047
- // v0.11.6 (#91): framework gaps → OpenVEX statements. Each gap becomes
1048
- // a statement with a pseudo-CVE id under the exceptd:framework-gap
1049
- // namespace so VEX downstreams ingest them cleanly.
1050
- const gapStatements = (analyze.framework_gap_mapping || []).map((g, idx) => ({
1051
- vulnerability: { '@id': `exceptd:framework-gap:${g.framework}:${g.claimed_control || idx}`, name: `${g.framework} ${g.claimed_control || `gap-${idx}`}` },
1052
- status: 'under_investigation',
1053
- timestamp: issued,
1054
- action_statement: g.required_control || null,
1055
- impact_statement: g.actual_gap || `Framework gap in ${g.framework}.`,
1056
- }));
1268
+ const productEntry = {
1269
+ '@id': productPurl,
1270
+ subcomponents: [{ '@id': productPurl }],
1271
+ };
1272
+ const remediationId = validate.selected_remediation?.id || (validate.remediation_paths?.[0]?.id) || null;
1273
+ const remediationDescription = validate.selected_remediation?.description || null;
1274
+ const actionStatementFor = (fallback) => {
1275
+ if (remediationId && remediationDescription) {
1276
+ return `Apply remediation from validate phase: ${remediationId}. ${remediationDescription}`;
1277
+ }
1278
+ if (remediationId) return `Apply remediation from validate phase: ${remediationId}`;
1279
+ if (remediationDescription) return `Apply remediation from validate phase: ${remediationDescription}`;
1280
+ return fallback;
1281
+ };
1282
+ const cveStatements = analyze.matched_cves.map(c => {
1283
+ const stmt = {
1284
+ vulnerability: { '@id': `urn:cve:${urnSlug(c.cve_id)}`, name: c.cve_id },
1285
+ products: [productEntry],
1286
+ timestamp: issued,
1287
+ impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`,
1288
+ };
1289
+ if (c.live_patch_available) {
1290
+ stmt.status = 'fixed';
1291
+ } else {
1292
+ stmt.status = 'affected';
1293
+ stmt.action_statement = actionStatementFor('Apply remediation from validate phase.');
1294
+ }
1295
+ return stmt;
1296
+ });
1297
+ const indicatorStatements = (analyze._detect_indicators || [])
1298
+ .filter(i => i.verdict === 'hit' || i.verdict === 'miss' || i.verdict === 'inconclusive')
1299
+ .map(i => {
1300
+ const stmt = {
1301
+ vulnerability: {
1302
+ '@id': `urn:exceptd:indicator:${playbookSlug}:${urnSlug(i.id)}`,
1303
+ name: i.id,
1304
+ },
1305
+ products: [productEntry],
1306
+ timestamp: issued,
1307
+ impact_statement: `Indicator ${i.id} (${i.verdict}; ${i.confidence}${i.deterministic ? '/deterministic' : ''}) in playbook ${playbook._meta.id}.`,
1308
+ };
1309
+ if (i.verdict === 'hit') {
1310
+ // Deterministic and high-confidence hits both map to `affected`.
1311
+ // The `deterministic` flag describes regex specificity, not
1312
+ // operator-evidence confidence — neither warrants
1313
+ // under_investigation when the indicator actually fired.
1314
+ stmt.status = 'affected';
1315
+ stmt.action_statement = actionStatementFor(`Run \`exceptd brief ${playbook._meta.id}\` for context.`);
1316
+ } else if (i.verdict === 'miss') {
1317
+ stmt.status = 'not_affected';
1318
+ stmt.justification = 'vulnerable_code_not_present';
1319
+ } else {
1320
+ stmt.status = 'under_investigation';
1321
+ }
1322
+ return stmt;
1323
+ });
1057
1324
  return {
1058
1325
  '@context': 'https://openvex.dev/ns/v0.2.0',
1059
- '@id': `https://exceptd.com/vex/${playbook._meta.id}/${Date.now()}`,
1326
+ '@id': `https://exceptd.com/vex/${playbookSlug}/${Date.now()}`,
1060
1327
  author: 'exceptd',
1061
1328
  timestamp: issued,
1062
1329
  version: 1,
1063
- statements: [...cveStatements, ...indicatorStatements, ...gapStatements],
1330
+ statements: [...cveStatements, ...indicatorStatements],
1064
1331
  };
1065
1332
  }
1066
1333
 
@@ -1160,7 +1427,12 @@ function normalizeSubmission(submission, playbook) {
1160
1427
  // v0.11.5 (#85): track which observation produced each signal_override so
1161
1428
  // detect can emit `from_observation` on each indicator result. Diagnostic
1162
1429
  // value for operators chasing "which observation drove this verdict".
1430
+ //
1431
+ // E9: when two observations target the same indicator id, last-write-wins
1432
+ // silently. Track discards in _signal_origins_collisions so analyze can
1433
+ // surface analyze.signal_origins_with_collisions for batch evidence runs.
1163
1434
  out._signal_origins = out._signal_origins || {};
1435
+ out._signal_origins_collisions = out._signal_origins_collisions || [];
1164
1436
  for (const [key, val] of Object.entries(submission.observations || {})) {
1165
1437
  if (knownPreconditions.has(key)) {
1166
1438
  out.precondition_checks[key] = val === "ok" || val === true || val === "true";
@@ -1170,7 +1442,20 @@ function normalizeSubmission(submission, playbook) {
1170
1442
  const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
1171
1443
  out.artifacts[aid] = { value: val.value, captured: val.captured !== false };
1172
1444
  if (val.indicator && val.result !== undefined) {
1173
- out.signal_overrides[val.indicator] = canonicalizeOutcome(val.result);
1445
+ const newVerdict = canonicalizeOutcome(val.result);
1446
+ if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
1447
+ // Collision: a prior observation already set this indicator.
1448
+ // Record the prior (which is now discarded) into the collision
1449
+ // log, then overwrite with the new one (last-write-wins).
1450
+ out._signal_origins_collisions.push({
1451
+ indicator_id: val.indicator,
1452
+ source_observation_key: out._signal_origins[val.indicator],
1453
+ verdict: out.signal_overrides[val.indicator],
1454
+ discarded: true,
1455
+ replaced_by: key
1456
+ });
1457
+ }
1458
+ out.signal_overrides[val.indicator] = newVerdict;
1174
1459
  out._signal_origins[val.indicator] = key;
1175
1460
  }
1176
1461
  }
@@ -1244,16 +1529,70 @@ function run(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
1244
1529
  // Cross-process mutex lock for this run. preflight verified no other lock
1245
1530
  // exists; we acquire ours and release in the finally block.
1246
1531
  const lockPath = acquireLock(playbookId);
1532
+ // E12: parse the playbook once at run() entry and thread the parsed object
1533
+ // through each phase via runOpts._playbookCache. Each phase otherwise calls
1534
+ // loadPlaybook() independently; for a single run that's seven reads + parses
1535
+ // of the same file. Cached version saves the redundant I/O + JSON parses.
1536
+ const cachedRunOpts = { ...runOpts, _playbookCache: playbook };
1537
+ // E3: run-time error accumulator for evalCondition regex failures and other
1538
+ // non-fatal anomalies surfaced into analyze.runtime_errors[].
1539
+ const runErrors = [];
1540
+ cachedRunOpts._runErrors = runErrors;
1541
+ // E6: phases the runner should SKIP execution for, based on skip_phase
1542
+ // preconditions surfaced in preflight.issues.
1543
+ const skipPhases = new Set();
1544
+ for (const issue of (pre.issues || [])) {
1545
+ if (issue.kind === 'precondition_skip' && issue.skip_phase) {
1546
+ skipPhases.add(issue.skip_phase);
1547
+ }
1548
+ }
1247
1549
  try {
1248
1550
  const phases = {
1249
- govern: govern(playbookId, directiveId, runOpts),
1250
- direct: direct(playbookId, directiveId),
1251
- look: look(playbookId, directiveId, runOpts),
1252
- detect: detect(playbookId, directiveId, agentSubmission),
1551
+ govern: govern(playbookId, directiveId, cachedRunOpts),
1552
+ direct: direct(playbookId, directiveId, cachedRunOpts),
1553
+ look: look(playbookId, directiveId, cachedRunOpts),
1253
1554
  };
1254
- phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {});
1255
- phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {});
1256
- phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, runOpts);
1555
+ if (skipPhases.has('detect')) {
1556
+ const skipIssue = (pre.issues || []).find(i => i.kind === 'precondition_skip' && i.skip_phase === 'detect');
1557
+ phases.detect = {
1558
+ phase: 'detect',
1559
+ playbook_id: playbookId,
1560
+ directive_id: directiveId,
1561
+ skipped: true,
1562
+ reason: skipIssue ? skipIssue.id : 'precondition_skip',
1563
+ classification: 'skipped',
1564
+ indicators: [],
1565
+ false_positive_checks_required: [],
1566
+ indicators_evaluated: [],
1567
+ indicators_evaluated_count: 0,
1568
+ observations_received: [],
1569
+ signals_received: []
1570
+ };
1571
+ // analyze() must still run, but with an empty submission so it doesn't
1572
+ // resolve indicator hits against a non-existent detect result.
1573
+ phases.analyze = analyze(playbookId, directiveId, phases.detect, {}, cachedRunOpts);
1574
+ // Annotate analyze with the skip vocabulary so consumers can branch.
1575
+ phases.analyze.classification = 'skipped';
1576
+ } else {
1577
+ phases.detect = detect(playbookId, directiveId, agentSubmission, cachedRunOpts);
1578
+ phases.analyze = analyze(playbookId, directiveId, phases.detect, agentSubmission.signals || {}, cachedRunOpts);
1579
+ }
1580
+ phases.validate = validate(playbookId, directiveId, phases.analyze, agentSubmission.signals || {}, cachedRunOpts);
1581
+ phases.close = close(playbookId, directiveId, phases.analyze, phases.validate, agentSubmission.signals || {}, cachedRunOpts);
1582
+
1583
+ // E3: analyze() already sliced runOpts._runErrors into
1584
+ // phases.analyze.runtime_errors at return time. Validate + close may
1585
+ // have pushed additional regex errors AFTER analyze returned; surface
1586
+ // those onto phases.analyze.runtime_errors so the field reflects every
1587
+ // regex failure in the run. De-dupe by JSON shape so the analyze-time
1588
+ // snapshot doesn't double-count.
1589
+ if (runErrors.length && phases.analyze) {
1590
+ const existing = new Set((phases.analyze.runtime_errors || []).map(e => JSON.stringify(e)));
1591
+ const additions = runErrors.filter(e => !existing.has(JSON.stringify(e)));
1592
+ if (additions.length) {
1593
+ phases.analyze.runtime_errors = (phases.analyze.runtime_errors || []).concat(additions);
1594
+ }
1595
+ }
1257
1596
 
1258
1597
  const sessionId = runOpts.session_id || crypto.randomBytes(8).toString('hex');
1259
1598
  const evidenceHash = crypto.createHash('sha256')
@@ -1340,7 +1679,23 @@ function evalCondition(expr, ctx, playbook) {
1340
1679
  if (m) {
1341
1680
  const val = resolvePath(ctx, m[1]);
1342
1681
  if (typeof val !== 'string') return false;
1343
- return new RegExp(m[2], 'i').test(val);
1682
+ // E3: an operator-supplied or playbook-supplied regex with a syntax bug
1683
+ // (or pathological backtracking) must NOT crash the engine mid-analyze.
1684
+ // Catch construction + test exceptions, return false, and push a
1685
+ // structured _regex_eval_error into ctx._runErrors (when present) so
1686
+ // analyze() can surface analyze.runtime_errors[] without losing the
1687
+ // diagnostic.
1688
+ try {
1689
+ return new RegExp(m[2], 'i').test(val);
1690
+ } catch (e) {
1691
+ const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
1692
+ // Two sites where ctx may carry an accumulator: runOpts._runErrors
1693
+ // (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
1694
+ // form; fall back to ctx.
1695
+ if (ctx && Array.isArray(ctx._runErrors)) ctx._runErrors.push(errorRec);
1696
+ else if (playbook && Array.isArray(playbook._runErrors)) playbook._runErrors.push(errorRec);
1697
+ return false;
1698
+ }
1344
1699
  }
1345
1700
 
1346
1701
  if (process.env.EXCEPTD_DEBUG) console.warn(`[runner] unknown condition: ${expr}`);
@@ -1398,13 +1753,35 @@ function stripOuterParens(expr) {
1398
1753
  return expr;
1399
1754
  }
1400
1755
 
1401
- function computeClockStart(eventName, agentSignals) {
1756
+ /**
1757
+ * Compute the start instant for a jurisdictional clock event. The agent
1758
+ * submits clock_started_at_<event> ISO strings as it progresses through
1759
+ * incident-response milestones.
1760
+ *
1761
+ * E7: per AGENTS.md Phase 7, the legal contract is that the clock starts
1762
+ * from OPERATOR AWARENESS — not from the moment the engine emits a
1763
+ * `detected` classification. Pre-fix, this auto-stamped Date.now() on
1764
+ * detect_confirmed whenever the engine classified as detected, which is
1765
+ * incorrect: the operator may not have seen the result yet. The corrected
1766
+ * semantics:
1767
+ *
1768
+ * - If the agent explicitly submits clock_started_at_<event>: use it.
1769
+ * - Otherwise, for 'detect_confirmed' with classification='detected':
1770
+ * stamp `now` ONLY if runOpts.operator_consent?.explicit === true
1771
+ * (i.e. the operator passed --ack). Without --ack, return null and
1772
+ * the caller (close()) surfaces clock_pending_ack: true on the
1773
+ * notification_actions entry so the operator sees that the clock is
1774
+ * waiting on acknowledgement.
1775
+ * - All other events without an explicit timestamp: return null.
1776
+ */
1777
+ function computeClockStart(eventName, agentSignals, runOpts = {}) {
1402
1778
  // The agent submits clock_started_at_<event> ISO strings as it progresses.
1403
1779
  const key = `clock_started_at_${eventName}`;
1404
- if (agentSignals[key]) return new Date(agentSignals[key]);
1405
- // Fallback: use the standard 'detect_confirmed' default of "now" for the
1406
- // most common case so notification deadlines aren't always pending.
1407
- if (eventName === 'detect_confirmed' && agentSignals.detection_classification === 'detected') {
1780
+ if (agentSignals && agentSignals[key]) return new Date(agentSignals[key]);
1781
+ // For detect_confirmed: only auto-stamp when the operator has explicitly
1782
+ // acknowledged the result via --ack. Otherwise leave the clock pending.
1783
+ if (eventName === 'detect_confirmed' && agentSignals?.detection_classification === 'detected'
1784
+ && runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true) {
1408
1785
  return new Date();
1409
1786
  }
1410
1787
  return null;