@blamejs/exceptd-skills 0.19.32 → 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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
@@ -1,76 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
- * lib/validate-playbooks.js — exceptd playbook validator.
4
- *
5
- * Walks every JSON file in data/playbooks/, validates it against
6
- * lib/schemas/playbook.schema.json (using the same inline JSON-Schema
7
- * subset validator as lib/validate-cve-catalog.js), and additionally
8
- * resolves every cross-playbook + cross-catalog reference the playbook
9
- * shape carries.
10
- *
11
- * Cross-references checked:
12
- * - _meta.feeds_into[].playbook_id → other playbook files
13
- * - _meta.mutex[] → other playbook files
14
- * - _meta.skill_chain[] → manifest.json.skills[]
15
- * (legacy alias; the canonical chain lives under
16
- * phases.direct.skill_chain[].skill — both are resolved)
17
- * - phases.govern.skill_preload[] → manifest.json.skills[]
18
- * - domain.atlas_refs[] → data/atlas-ttps.json keys
19
- * - domain.attack_refs[] → data/attack-techniques.json keys
20
- * - domain.cve_refs[] → data/cve-catalog.json keys
21
- * - domain.cwe_refs[] → data/cwe-catalog.json keys
22
- * - domain.d3fend_refs[] → data/d3fend-catalog.json keys
23
- * - phases.detect.indicators[].attack_ref → data/attack-techniques.json
24
- * - phases.detect.indicators[].atlas_ref → data/atlas-ttps.json
25
- * - phases.detect.indicators[].cve_ref → data/cve-catalog.json
26
- * - phases.detect.false_positive_profile[].indicator_id
27
- * → phases.detect.indicators[].id
28
- * - directives[].applies_to.cve → data/cve-catalog.json keys
29
- * - directives[].applies_to.atlas_ttp → data/atlas-ttps.json keys
30
- * - directives[].applies_to.attack_technique → data/attack-techniques.json
31
- * - directives[].phase_overrides.govern.jurisdiction_obligations[].clock_starts
32
- * → closed clock_starts vocabulary
33
- * - directives[].phase_overrides.direct.rwep_threshold → ordering + range
34
- * - directives[].phase_overrides.close.notification_actions[].obligation_ref
35
- * → effective jurisdiction_obligations
36
- *
37
- * Internal consistency:
38
- * - Indicator ids are unique within a playbook.
39
- * - Every playbook maps to at least one TTP via domain.atlas_refs or
40
- * domain.attack_refs (the cross-cutting correlation layer is exempt).
41
- * - When _meta.air_gap_mode is true, network-sourced look.artifacts carry
42
- * a non-empty air_gap_alternative (error if missing).
43
- * - Closed controlled vocabularies (jurisdiction_obligations[].clock_starts,
44
- * domain.frameworks_in_scope[]) are enforced at error severity, unlike the
45
- * evolving-drift enums (artifact/indicator `type`) which stay warnings.
46
- * - rwep_threshold ordering: close <= monitor <= escalate, each in 0..100.
47
- * - close.notification_actions[].obligation_ref resolves to a synthesized
48
- * "<jurisdiction>/<regulation> <window_hours>h" key from
49
- * govern.jurisdiction_obligations[] (the schema does not give
50
- * jurisdiction_obligations an explicit `id` field; the shipped playbooks
51
- * reference them by this composite string).
52
- * - _meta.mutex is symmetric across the whole playbook set: if A lists B,
53
- * B must list A. Asymmetry surfaces as a warning by default (promoted to
54
- * an error under --strict) — see checkMutexReciprocity().
55
- *
56
- * Finding severity:
57
- * - error — structural problems that block the runner (missing required
58
- * field, JSON parse error, internal ordering violation,
59
- * duplicate indicator id).
60
- * - warning — schema-shape drift the runner can still tolerate (enum
61
- * vocabulary lag, cross-catalog refs introduced after the
62
- * playbook last shipped). Surfaced to the operator without
63
- * failing the gate by default; promoted to hard errors under
64
- * --strict (predeploy `informational: false`).
65
- *
66
- * Exit code: 0 if no errors (warnings allowed), 1 if any errors, 2 on
67
- * argv error.
68
- *
69
- * Usage:
70
- * node lib/validate-playbooks.js validate every playbook
71
- * node lib/validate-playbooks.js --quiet only print FAIL playbooks + summary
72
- * node lib/validate-playbooks.js --strict treat warnings as errors (used by
73
- * the predeploy gate).
3
+ * lib/validate-playbooks.js — validates every data/playbooks/*.json against
4
+ * lib/schemas/playbook.schema.json and resolves the cross-references it carries.
5
+ * Findings are `error` (blocks the runner) or `warning` (tolerated drift);
6
+ * --strict promotes every warning to an error. Exits 0 clean, 1 on any error,
7
+ * 2 on an argv error.
74
8
  */
75
9
 
76
10
  'use strict';
@@ -79,33 +13,15 @@ const fs = require('node:fs');
79
13
  const path = require('node:path');
80
14
  const process = require('node:process');
81
15
  const { safeExit } = require('./exit-codes');
82
- // _evalCondition is the SAME parser the runner uses at analyze/validate/close
83
- // time. Exercising every escalation / feeds_into / remediation-precondition
84
- // condition through the real evaluator lets the validator reject a condition the
85
- // engine cannot parse (condition_unparsed) — a prose / bare-token /
86
- // unimplemented-syntax condition that would silently return false for every
87
- // input and disable the escalation it gates. No cycle: playbook-runner does not
88
- // require this validator. Loaded defensively: the engine carries a dependency
89
- // closure (scoring / cross-ref-api / id-validation) that a stripped test mirror
90
- // staging only the validator + schema does not copy, so a hard top-level require
91
- // would crash the validator there. In the real repo and the shipped tarball the
92
- // engine is always present, so the parse-gate always runs; when it is genuinely
93
- // unresolvable the gate surfaces a WARNING (never a silent error-skip — see
94
- // checkCrossRefs) so the degradation is observable rather than a false pass.
16
+ // The SAME parser the runner uses, so a condition the engine cannot parse is
17
+ // rejected here instead of silently returning false for every input. Required
18
+ // defensively: a stripped test mirror stages this validator without the engine.
95
19
  let _evalCondition = null;
96
20
  try { ({ _evalCondition } = require('./playbook-runner')); } catch { /* warned at the gate */ }
97
21
 
98
- // Decompose a condition into its leaf atoms the SAME way evalCondition does —
99
- // strip wrapping parens, split on top-level OR (lowest precedence), then on
100
- // top-level AND, quote- and depth-aware. This exists because evalCondition
101
- // evaluates AND via `.every` / OR via `.some`, which SHORT-CIRCUIT: a dead
102
- // (unparseable) sub-clause behind a leading clause that resolves false (AND) or
103
- // true (OR) in the empty validation context is never reached, so running the
104
- // whole condition once never surfaces its condition_unparsed. Atomizing and
105
- // parse-checking each leaf independently catches a dead sub-clause regardless of
106
- // short-circuit (e.g. `blast_radius_score >= 3 AND any deterministic indicator
107
- // fires` — the first clause is false with no signals, so the dead prose second
108
- // clause never parsed). Mirrors evalCondition's splitAtTopLevel / stripOuterParens.
22
+ // Decompose a condition into leaf atoms the way evalCondition does. Checking each
23
+ // leaf independently catches a dead sub-clause: evalCondition short-circuits AND
24
+ // via `.every` and OR via `.some`, so a later clause never reports unparsed.
109
25
  function _splitTopLevel(expr, sep) {
110
26
  const parts = []; const needle = ' ' + sep + ' ';
111
27
  let depth = 0, buf = '', i = 0, quote = null;
@@ -193,13 +109,9 @@ function typeOf(value) {
193
109
  return typeof value;
194
110
  }
195
111
 
196
- // Strict ISO calendar-date check for format:date fields. The shape regex alone
197
- // accepts impossible dates — `new Date('2026-02-30T00:00:00Z')` does NOT throw,
198
- // it silently rolls over to March 2 and reports a valid getTime() — so a
199
- // malformed _meta.last_threat_review or changelog `date` (2026-02-30,
200
- // 2026-13-40) would pass with only the YYYY-MM-DD shape test. Require the
201
- // parsed Y-M-D to round-trip back to the input components, mirroring
202
- // parseIsoDateStrict in lib/validate-catalog-meta.js.
112
+ // Strict ISO calendar check: the shape regex alone accepts impossible dates —
113
+ // `new Date('2026-02-30T00:00:00Z')` rolls over to March 2 — so the parsed Y-M-D
114
+ // must round-trip. Mirrors parseIsoDateStrict in lib/validate-catalog-meta.js.
203
115
  function isStrictIsoDate(value) {
204
116
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
205
117
  const d = new Date(value + 'T00:00:00Z');
@@ -219,12 +131,10 @@ function typeMatches(value, expected) {
219
131
  return actual === expected;
220
132
  }
221
133
 
222
- /* Inline JSON-Schema subset validator. Returns a flat list of finding objects
223
- * shaped as { severity, message }. Severity defaults to 'error'; enum
224
- * mismatches and unknown additional properties under
225
- * additionalProperties:false are downgraded to 'warning' so vocabulary drift
226
- * between the schema and shipped playbooks does not hard-fail by default.
227
- * Promoted to errors under --strict / predeploy informational:false. */
134
+ /* Inline JSON-Schema subset validator. Returns a flat list of
135
+ * { severity, message }: 'error' except enum mismatches and unknown properties
136
+ * under additionalProperties:false, which are 'warning' so vocabulary drift does
137
+ * not hard-fail outside --strict. */
228
138
  function validate(value, schema, schemaName, pathStr) {
229
139
  const findings = [];
230
140
  const here = pathStr || schemaName;
@@ -239,8 +149,6 @@ function validate(value, schema, schemaName, pathStr) {
239
149
 
240
150
  if (schema.enum !== undefined) {
241
151
  if (!schema.enum.includes(value)) {
242
- // Enum drift is downgraded to a warning so vocabulary-evolution does
243
- // not break patch-class releases.
244
152
  err(
245
153
  `${here}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`,
246
154
  'warning',
@@ -315,8 +223,6 @@ function validate(value, schema, schemaName, pathStr) {
315
223
  } else if (addlSchema) {
316
224
  findings.push(...validate(v, addlSchema, schemaName, `${here}.${k}`));
317
225
  } else if (!allowAdditional) {
318
- // Drift between schema and shipped data: surface as warning, not
319
- // an error. Promoted to an error under --strict.
320
226
  err(`${here}: unexpected property "${k}"`, 'warning');
321
227
  }
322
228
  }
@@ -331,19 +237,11 @@ function loadContext() {
331
237
  const cve = readJson(CVE_PATH);
332
238
  const cwe = readJson(CWE_PATH);
333
239
  const d3 = readJson(D3FEND_PATH);
334
- // Required, like the other ref catalogs (cwe / d3fend). It is committed and
335
- // always present; loading it optionally let attack_ref validation skip
336
- // silently when it was absent — the asymmetry that allowed an unresolvable
337
- // attack_ref to ship. Its absence must fail loud.
240
+ // Required, not optional: an optional load skips attack_ref validation silently.
338
241
  const attack = readJson(ATTACK_PATH);
339
242
 
340
- // Closed controlled-vocabulary enums sourced from the schema so the
341
- // hard-error enum checks in checkCrossRefs stay in lockstep with the
342
- // schema's own enum lists. A typo'd clock_starts must hard-fail the
343
- // predeploy gate (it changes when a notification clock starts ticking),
344
- // so unlike evolving-drift enums (artifact/indicator `type`) these are
345
- // promoted to error severity rather than left as generic-validator
346
- // warnings.
243
+ // Sourced from the schema so the hard-error checks in checkCrossRefs stay in
244
+ // lockstep with its enum lists.
347
245
  let clockStartsEnum = null;
348
246
  let frameworksEnum = null;
349
247
  try {
@@ -354,13 +252,10 @@ function loadContext() {
354
252
  frameworksEnum =
355
253
  schema.properties.domain.properties.frameworks_in_scope.items.enum || null;
356
254
  } catch (e) {
357
- // The playbook schema is a committed, required file. Swallowing a read error
358
- // here silently disabled the clock_starts / frameworks_in_scope closed-vocab
359
- // checks, so a typo in those fields could ship. Fail loud instead.
360
255
  throw new Error(`validate-playbooks: cannot read playbook schema ${SCHEMA_PATH} — ${e && e.message}. The closed-vocabulary checks must not silently disable.`);
361
256
  }
362
- // A successful parse with the wrong shape (enum path moved/renamed) would also
363
- // leave the enums null and disable the checks. Treat that as fatal too.
257
+ // A wrong-shape parse leaves the enums null and disables the checks, so it is
258
+ // fatal too.
364
259
  if (!Array.isArray(clockStartsEnum) || !Array.isArray(frameworksEnum)) {
365
260
  throw new Error(`validate-playbooks: playbook schema ${SCHEMA_PATH} did not yield the clock_starts / frameworks_in_scope enums (shape changed). Refusing to validate with the closed-vocab checks silently disabled — fix the schema path expressions in loadContext().`);
366
261
  }
@@ -397,21 +292,14 @@ function loadPlaybooks() {
397
292
  }
398
293
 
399
294
  function obligationKey(o) {
400
- // The schema does not define an explicit `id` field on
401
- // jurisdiction_obligations entries; the shipped playbooks reference them
402
- // by the composite "<jurisdiction>/<regulation> <window_hours>h" string.
295
+ // Obligations carry no `id`; playbooks reference one by this composite string.
403
296
  return `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
404
297
  }
405
298
 
406
299
  function checkCrossRefs(playbook, ctx, playbookIds) {
407
300
  const findings = [];
408
- // A null/array/primitive playbook has no cross-refs to check, and validate()
409
- // already emits the top-level `expected type "object", got null` error for
410
- // it (main() line ~755). Guarding here turns the uncaught `playbook._meta`
411
- // TypeError on a literal-null playbook file into a clean no-op so main()
412
- // still reports the FAIL and continues to the remaining playbooks instead of
413
- // aborting the whole gate. The FAIL is preserved — it originates in
414
- // validate(), not here.
301
+ // validate() already emits the type error; returning empty keeps main()
302
+ // reporting the FAIL instead of dying on `playbook._meta`.
415
303
  if (!playbook || typeof playbook !== 'object' || Array.isArray(playbook)) {
416
304
  return findings;
417
305
  }
@@ -431,9 +319,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
431
319
  warn(`_meta.mutex: unresolved playbook_id "${m}"`);
432
320
  }
433
321
  }
434
- // Some playbooks may carry a legacy _meta.skill_chain[] (string list); the
435
- // canonical chain lives at phases.direct.skill_chain[].skill but we still
436
- // resolve a flat list if present, per the task brief.
322
+ // _meta.skill_chain[] aliases the canonical phases.direct.skill_chain[].skill.
437
323
  for (const s of meta.skill_chain || []) {
438
324
  if (typeof s === 'string' && !ctx.skillKeys.has(s)) {
439
325
  warn(`_meta.skill_chain: unresolved skill "${s}"`);
@@ -459,10 +345,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
459
345
  warn(`domain.atlas_refs: unresolved "${a}" (not in data/atlas-ttps.json)`);
460
346
  }
461
347
  }
462
- // Hard Rule #4 ("no orphaned controls"): domain.attack_refs must resolve to
463
- // the ATT&CK technique catalog, mirroring the atlas_refs block above and the
464
- // detect.indicators[].attack_ref check below. Without this, every TTP listed
465
- // at the domain level bypassed catalog cross-referencing.
348
+ // Hard Rule #4: domain-level TTPs resolve against the ATT&CK catalog.
466
349
  for (const a of domain.attack_refs || []) {
467
350
  if (ctx.attackKeys && !ctx.attackKeys.has(a)) {
468
351
  warn(`domain.attack_refs: unresolved "${a}" (not in data/attack-techniques.json)`);
@@ -484,7 +367,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
484
367
  }
485
368
  }
486
369
 
487
- // Indicators: id uniqueness, attack_ref / atlas_ref / cve_ref resolution.
488
370
  const detect = phases.detect || {};
489
371
  const indIds = new Set();
490
372
  const indicators = detect.indicators || [];
@@ -516,10 +398,7 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
516
398
  }
517
399
  }
518
400
 
519
- // false_positive_profile[].indicator_id must reference a real indicator id.
520
- // A dangling reference means an FP-distinguishing test is wired to nothing,
521
- // so the runner can never apply it. Warning severity (vocabulary-style
522
- // drift, not a structural break).
401
+ // A dangling indicator_id wires an FP test to nothing the runner can apply.
523
402
  for (const [i, fp] of (detect.false_positive_profile || []).entries()) {
524
403
  if (!fp || typeof fp !== 'object') continue;
525
404
  if (fp.indicator_id && !indIds.has(fp.indicator_id)) {
@@ -529,12 +408,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
529
408
  }
530
409
  }
531
410
 
532
- // validate.remediation_paths[].for_signals[] must reference real indicator
533
- // ids. A dangling ref silently never matches, so selected_remediation falls
534
- // back to priority-1 without surfacing the intended finding-specific link —
535
- // exactly the kind of "looks wired, does nothing" drift this gate exists to
536
- // catch. Warning severity (promoted to a hard error under --strict, matching
537
- // the false_positive_profile precedent above).
411
+ // A dangling for_signals never matches: selected_remediation falls back to
412
+ // priority 1 without the finding-specific link.
538
413
  const validatePhase = phases.validate || {};
539
414
  for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
540
415
  if (!rp || typeof rp !== 'object' || !Array.isArray(rp.for_signals)) continue;
@@ -547,18 +422,11 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
547
422
  }
548
423
  }
549
424
 
550
- // rwep_threshold ordering. Hard error — a misordered threshold actively
551
- // breaks the scoring path. Factored into a helper so the same check runs
552
- // against the playbook-level direct phase AND any directive-level
553
- // phase_overrides.direct copy the runner deep-merges at run time.
425
+ // Helpers, so the same checks run against a directive's phase_overrides copy.
554
426
  checkRwepThreshold(direct.rwep_threshold, 'phases.direct.rwep_threshold');
555
427
 
556
- // clock_starts closed-vocab against the base govern phase. Factored into a
557
- // helper for the same reason — an override-supplied jurisdiction_obligations
558
- // copy must pass the same closed-vocabulary gate.
559
428
  checkClockStarts(govern.jurisdiction_obligations, 'phases.govern.jurisdiction_obligations');
560
429
 
561
- // notification_actions obligation_ref resolution.
562
430
  const obligationKeys = new Set(
563
431
  (govern.jurisdiction_obligations || []).map(obligationKey),
564
432
  );
@@ -572,15 +440,9 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
572
440
  }
573
441
  }
574
442
 
575
- // Escalation / feeds_into condition path-root resolvability. The
576
- // analyze-phase escalation context resolves the flat keys (rwep,
577
- // blast_radius_score, theater_verdict, agent signals) plus the `analyze`
578
- // and `finding` roots; close()'s feeds_into context additionally resolves
579
- // `validate` and `theater_score`. A dotted path rooted at any OTHER phase
580
- // name can never resolve at evaluation time — the condition would
581
- // silently never fire — so it is rejected here. Bare (un-dotted)
582
- // identifiers are agent-signal names, an open vocabulary, and are not
583
- // checked.
443
+ // The escalation context resolves the flat keys plus `analyze` and `finding`;
444
+ // feeds_into also resolves `validate`. A dotted path rooted at any other phase
445
+ // name never resolves. Bare identifiers are agent-signal names, not checked.
584
446
  const conditionPathRoots = (cond) => {
585
447
  if (typeof cond !== 'string') return [];
586
448
  const stripped = cond.replace(/'[^']*'|"[^"]*"|\/[^/\n]*\//g, ' ');
@@ -614,31 +476,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
614
476
  }
615
477
  }
616
478
 
617
- // Condition PARSEABILITY gate. The path-root check above only catches a
618
- // dotted path rooted at an unavailable phase; the broader and far more common
619
- // dead-condition shape is a condition the evaluator cannot parse AT ALL —
620
- // free-text prose ("A single compromised identity can rewrite the trail"), a
621
- // bare-truthiness token without `== true`, an unimplemented keyword
622
- // (`<tok> fired`, `not in`, `is`), or an apostrophe-bearing token that fails
623
- // the LHS pattern. Every such condition falls through evalCondition to a
624
- // silent `false` for every input, so the raise_severity / trigger_playbook /
625
- // remediation-precondition it gates never fires — and nothing flagged it,
626
- // because the validator never ran the actual evaluator. Run each condition in
627
- // the THREE locations the runner threads through evalCondition (analyze
628
- // escalation_criteria, _meta.feeds_into, validate remediation_paths
629
- // preconditions) and hard-fail on condition_unparsed. Scoped to exactly those
630
- // three: scoring_rubric entries are descriptive blast-level prose (never
631
- // evaluated — the score comes from agentSignals.blast_radius_score) and
632
- // regression_trigger conditions are consumed by computeRegressionNextRun's own
633
- // cadence/event parser, so neither is in scope here. Gate ONLY on
634
- // condition_unparsed — a genuine PARSE failure is input-independent;
635
- // condition_path_unresolved / condition_type_mismatch are runtime data
636
- // diagnostics that would false-positive against this empty validation context
637
- // (a dotted path legitimately resolves null when no run has populated it).
479
+ // Parseability gate over the three places the runner threads a condition through
480
+ // evalCondition: analyze escalation_criteria, _meta.feeds_into, and validate
481
+ // remediation_paths preconditions. Only condition_unparsed gates: a parse failure
482
+ // is input-independent, where condition_path_unresolved and
483
+ // condition_type_mismatch are runtime diagnostics that false-positive here.
638
484
  if (!_evalCondition) {
639
- // Engine module not resolvable from this validator location (a stripped
640
- // test mirror). Surface it as a WARNING so the skip is observable — in the
641
- // real repo + shipped tarball the engine always loads and this never fires.
642
485
  warn(
643
486
  'escalation/feeds_into/precondition parse-gate skipped: lib/playbook-runner.js (the condition evaluator) was not resolvable from this validator location, so condition parseability was not checked',
644
487
  );
@@ -646,9 +489,6 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
646
489
  const conditionParses = (cond) => {
647
490
  if (typeof cond !== 'string' || !cond.trim()) return true;
648
491
  if (!_evalCondition) return true; // engine unavailable — warned above, never a silent error-skip
649
- // Check EACH atomic leaf, not the whole condition once: evalCondition's
650
- // AND/OR short-circuit hides a dead sub-clause behind a leading clause that
651
- // resolves false (AND) / true (OR) in the empty validation context.
652
492
  for (const atom of atomizeCondition(cond)) {
653
493
  const runErrors = [];
654
494
  try { _evalCondition(atom, { _runErrors: runErrors }, { _runErrors: runErrors }); }
@@ -682,26 +522,15 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
682
522
  }
683
523
  }
684
524
 
685
- // Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
686
- // to touch the network, so every artifact whose source is a network call
687
- // (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a
688
- // non-empty air_gap_alternative or the run is silently incomplete. The
689
- // schema encodes this as an allOf/if/then block, but the inline validator
690
- // does not implement conditional keywords, so it is enforced imperatively
691
- // here at error severity.
525
+ // Under _meta.air_gap_mode the runner refuses the network, so a network-sourced
526
+ // artifact needs a non-empty air_gap_alternative. The schema states this as an
527
+ // allOf/if/then block the inline validator does not implement.
692
528
  if (meta.air_gap_mode === true) {
693
529
  const look = phases.look || {};
694
- // Case-insensitive + word-bounded so `HTTPS://`, `Curl`, and `fetch(` (no
695
- // trailing space) still flag a network source — otherwise an artifact could
696
- // ship under air_gap_mode with no offline alternative and run incomplete.
697
- // Network-source detection includes API-verb-phrased sources ("GET
698
- // /directoryRoles via Graph", "Entra ID", "Okta", "Microsoft Graph") so a
699
- // REST/Graph endpoint described in prose still flags under air_gap_mode and
700
- // is not silently collected offline-incomplete. `api/v\d` is deliberately
701
- // NOT a token — it false-positives on local code-scan artifacts that merely
702
- // reference an API path. NOTE: lib/schemas/playbook.schema.json carries the
703
- // same narrow `source` pattern and must be broadened in lockstep with this
704
- // regex (main-thread item — that file is not edited here).
530
+ // `api/v\d` is deliberately not a token — it false-positives on local
531
+ // code-scan artifacts that merely reference an API path. The same `source`
532
+ // pattern lives in lib/schemas/playbook.schema.json and must be broadened
533
+ // in lockstep with this one.
705
534
  const netSourceRe = /(https?:\/\/|\bgh (?:api|release)\b|\bcurl\b|\bwget\b|\bfetch\b|\b(?:GET|POST|PUT|PATCH|DELETE)\s+\/|\bGraph\b|\b(?:Okta|Entra ID|Microsoft Graph)\b)/i;
706
535
  for (const [i, art] of (look.artifacts || []).entries()) {
707
536
  if (!art || typeof art !== 'object') continue;
@@ -716,11 +545,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
716
545
  }
717
546
  }
718
547
 
719
- // TTP-mapping floor (Hard Rule #4): every playbook must map to at least one
720
- // adversary technique via domain.atlas_refs OR domain.attack_refs. The sole
721
- // exemption is the cross-cutting correlation layer (_meta.scope ===
722
- // "cross-cutting"), which has no first-party TTPs — it correlates findings
723
- // produced by the other playbooks. Error severity for everything else.
548
+ // TTP-mapping floor (Hard Rule #4): every playbook maps to at least one
549
+ // technique. The cross-cutting correlation layer has no first-party TTPs.
724
550
  const atlasCount = (domain.atlas_refs || []).length;
725
551
  const attackCount = (domain.attack_refs || []).length;
726
552
  if (atlasCount === 0 && attackCount === 0 && meta.scope !== 'cross-cutting') {
@@ -729,9 +555,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
729
555
  );
730
556
  }
731
557
 
732
- // frameworks_in_scope closed vocabulary. A value outside the schema's closed
733
- // enum is an error, not a warning, so a typo cannot ship — frameworks_in_scope
734
- // drives gap-analysis routing.
558
+ // frameworks_in_scope drives gap-analysis routing, so a value outside the
559
+ // schema's closed enum errors rather than warns.
735
560
  if (ctx.frameworksEnum) {
736
561
  for (const [i, f] of (domain.frameworks_in_scope || []).entries()) {
737
562
  if (typeof f === 'string' && !ctx.frameworksEnum.has(f)) {
@@ -742,19 +567,12 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
742
567
  }
743
568
  }
744
569
 
745
- // Directive-level coverage. A directive's applies_to fields and its
746
- // phase_overrides both reach the runner live (the runner selects directives
747
- // by id, deep-merges phase_overrides into the base phase, and surfaces
748
- // applies_to in the discovery API) but neither was cross-referenced or
749
- // re-validated — so a stale CVE/TTP reference or a tampered override
750
- // (bogus clock_starts, out-of-range rwep_threshold) shipped past this gate
751
- // even though the identical content is a hard error at playbook level.
570
+ // Directives reach the runner live — phase_overrides deep-merged into the base
571
+ // phase — so they face the same cross-reference and override checks.
752
572
  for (const [i, d] of (playbook.directives || []).entries()) {
753
573
  if (!d || typeof d !== 'object') continue;
754
574
  const label = d.id ? `directives[${i}] (${d.id})` : `directives[${i}]`;
755
575
 
756
- // applies_to.{cve,atlas_ttp,attack_technique} resolution, mirroring the
757
- // domain-ref checks at warning severity (promoted to error under --strict).
758
576
  const at = d.applies_to;
759
577
  if (at && typeof at === 'object') {
760
578
  if (at.cve && !ctx.cveKeys.has(at.cve)) {
@@ -763,17 +581,13 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
763
581
  if (at.atlas_ttp && !ctx.atlasKeys.has(at.atlas_ttp)) {
764
582
  warn(`${label}.applies_to.atlas_ttp: unresolved "${at.atlas_ttp}" (not in data/atlas-ttps.json)`);
765
583
  }
766
- // Guard the attack catalog the same way domain.attack_refs does:
767
- // attack-techniques.json is loaded via readJsonIfExists and may be null.
768
584
  if (at.attack_technique && ctx.attackKeys && !ctx.attackKeys.has(at.attack_technique)) {
769
585
  warn(`${label}.applies_to.attack_technique: unresolved "${at.attack_technique}" (not in data/attack-techniques.json)`);
770
586
  }
771
587
  }
772
588
 
773
- // phase_overrides re-validation. The runner merges these into the base
774
- // phase before govern()/close() consume them, so an override-supplied
775
- // clock_starts or rwep_threshold must pass the same gates as the base
776
- // phase or the regulatory clock / scoring path breaks at run time.
589
+ // The runner merges these into the base phase, so an override-supplied
590
+ // clock_starts or rwep_threshold must clear the same gates.
777
591
  const ov = d.phase_overrides;
778
592
  if (ov && typeof ov === 'object') {
779
593
  if (ov.govern && typeof ov.govern === 'object') {
@@ -788,10 +602,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
788
602
  `${label}.phase_overrides.direct.rwep_threshold`,
789
603
  );
790
604
  }
791
- // An override-supplied notification obligation_ref must resolve against
792
- // the EFFECTIVE obligation set the runner sees after the merge: the
793
- // base govern obligations, plus any the override adds. Warning severity,
794
- // matching the base-phase obligation_ref precedent.
605
+ // Resolved against the effective set the runner sees after the merge: the
606
+ // override's own obligations when it supplies them, else the base phase's.
795
607
  if (ov.close && typeof ov.close === 'object' && Array.isArray(ov.close.notification_actions)) {
796
608
  const overrideObligations =
797
609
  (ov.govern && Array.isArray(ov.govern.jurisdiction_obligations))
@@ -812,29 +624,21 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
812
624
 
813
625
  return findings;
814
626
 
815
- // ---- local helpers (hoisted; close over `findings`/`ctx`/`err`) ----
627
+ // Hoisted for the calls above; they close over `findings`, `ctx` and `err`.
816
628
 
817
- // rwep_threshold ordering + range. close <= monitor <= escalate, each in
818
- // 0..100. Error severity — a misordered or out-of-range threshold actively
819
- // breaks the scoring path. `pathPrefix` keeps the message accurate whether
820
- // the source is the base phase or a directive override.
629
+ // close <= monitor <= escalate, each in 0..100. `pathPrefix` keeps the message
630
+ // accurate for both the base phase and a directive override.
821
631
  function checkRwepThreshold(rwepObj, pathPrefix) {
822
632
  const rwep = rwepObj || {};
823
- // Range check is PER-KEY and independent of the others. A directive's
824
- // phase_overrides.direct.rwep_threshold is a partial fragment the runner
825
- // deep-merges, so it may carry only one of the three keys — gating the
826
- // whole check on all-three-present let an out-of-range single-key override
827
- // (e.g. {escalate: 150} or {close: -5}) ship unchecked and reach the
828
- // runner. Validate each present numeric key on its own.
633
+ // Per-key and independent: an override is a partial fragment, so gating the
634
+ // range check on all three keys present lets `{escalate: 150}` through.
829
635
  for (const k of ['close', 'monitor', 'escalate']) {
830
636
  const v = rwep[k];
831
637
  if (typeof v === 'number' && (v < 0 || v > 100)) {
832
638
  err(`${pathPrefix}.${k}: ${v} outside 0..100`);
833
639
  }
834
640
  }
835
- // Ordering is only meaningful when the full triple is present (a partial
836
- // override inherits the missing edges from the base phase, so close <=
837
- // monitor <= escalate can't be evaluated on the fragment alone).
641
+ // Ordering needs the full triple: a partial override inherits its missing edges.
838
642
  if (
839
643
  typeof rwep.close === 'number' &&
840
644
  typeof rwep.monitor === 'number' &&
@@ -848,9 +652,8 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
848
652
  }
849
653
  }
850
654
 
851
- // clock_starts closed-vocabulary check over a jurisdiction_obligations list.
852
- // Error severity — clock_starts decides when a notification deadline starts
853
- // counting; an out-of-vocabulary value silently never starts the clock.
655
+ // clock_starts decides when a notification deadline begins counting; an
656
+ // out-of-vocabulary value never starts the clock.
854
657
  function checkClockStarts(obligations, pathPrefix) {
855
658
  if (!ctx.clockStartsEnum || !Array.isArray(obligations)) return;
856
659
  for (const [i, o] of obligations.entries()) {
@@ -864,19 +667,10 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
864
667
  }
865
668
  }
866
669
 
867
- /* Cross-playbook mutex-reciprocity check.
868
- *
869
- * `_meta.mutex` is a symmetric relation: if playbook A lists B, B must list A.
870
- * Asymmetry is a latent runner bug — the engine's mutex enforcement only
871
- * blocks concurrent execution from whichever side declared the conflict, so
872
- * an asymmetric declaration silently degrades to a race condition when the
873
- * undeclared side is started first.
874
- *
875
- * Emits one warning per asymmetric pair (keyed off the side that declares
876
- * the edge). Kept at warning severity by default per the patch-class
877
- * cadence; promoted to an error under --strict / predeploy
878
- * `informational: false`.
879
- */
670
+ /* `_meta.mutex` is symmetric: if playbook A lists B, B must list A. The engine
671
+ * blocks concurrency only from the side that declared the conflict, so an
672
+ * asymmetric declaration degrades to a race. Returns one warning per asymmetric
673
+ * pair, keyed by the declaring playbook. */
880
674
  function checkMutexReciprocity(playbooks) {
881
675
  const mutexMap = new Map();
882
676
  for (const pb of playbooks) {