@blamejs/exceptd-skills 0.18.13 → 0.18.15

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 (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/exceptd.js +26 -0
  3. package/data/_indexes/_meta.json +2 -2
  4. package/data/playbooks/ai-api.json +2 -2
  5. package/data/playbooks/ai-discovered-cve-triage.json +1 -1
  6. package/data/playbooks/audit-log-integrity.json +3 -3
  7. package/data/playbooks/cicd-pipeline-compromise.json +1 -1
  8. package/data/playbooks/citation-hygiene.json +4 -4
  9. package/data/playbooks/containers.json +2 -2
  10. package/data/playbooks/cred-stores.json +1 -1
  11. package/data/playbooks/crypto-codebase.json +3 -3
  12. package/data/playbooks/crypto.json +1 -1
  13. package/data/playbooks/decompression-dos.json +3 -3
  14. package/data/playbooks/framework.json +1 -1
  15. package/data/playbooks/hardening.json +1 -1
  16. package/data/playbooks/identity-sso-compromise.json +1 -1
  17. package/data/playbooks/idp-incident.json +1 -1
  18. package/data/playbooks/kernel.json +2 -2
  19. package/data/playbooks/library-author.json +1 -1
  20. package/data/playbooks/llm-tool-use-exfil.json +1 -1
  21. package/data/playbooks/log-injection-telemetry.json +3 -3
  22. package/data/playbooks/mail-server-hardening.json +3 -3
  23. package/data/playbooks/mcp.json +3 -3
  24. package/data/playbooks/multitenancy-isolation.json +3 -3
  25. package/data/playbooks/network-trust.json +3 -3
  26. package/data/playbooks/post-quantum-migration.json +1 -1
  27. package/data/playbooks/privacy-consent-ops.json +3 -3
  28. package/data/playbooks/runtime.json +2 -2
  29. package/data/playbooks/sbom.json +1 -1
  30. package/data/playbooks/secrets.json +1 -1
  31. package/data/playbooks/self-update-integrity.json +3 -3
  32. package/data/playbooks/supply-chain-recovery.json +2 -2
  33. package/data/playbooks/vc-wallet-trust.json +5 -5
  34. package/data/playbooks/webhook-callback-abuse.json +1 -1
  35. package/lib/playbook-runner.js +86 -5
  36. package/lib/validate-playbooks.js +132 -0
  37. package/manifest.json +53 -53
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +80 -80
@@ -528,11 +528,11 @@
528
528
  ],
529
529
  "escalation_criteria": [
530
530
  {
531
- "condition": "An internet-facing verifier accepts an unanchored issuer key OR a replayable presentation gating a high-value entitlement",
531
+ "condition": "issuer-key-not-pinned-to-trust-anchor == true OR presentation-no-nonce-audience-binding == true",
532
532
  "action": "raise_severity"
533
533
  },
534
534
  {
535
- "condition": "Revocation is unenforced on a credential type used for ongoing authorization",
535
+ "condition": "credential-revocation-status-not-checked == true",
536
536
  "action": "trigger_playbook"
537
537
  }
538
538
  ]
@@ -543,7 +543,7 @@
543
543
  "id": "pin-issuer-trust-anchors",
544
544
  "description": "Replace open issuerKeyResolver acceptance with an issuer allowlist / x5c-or-federation chain validated to a configured trust anchor; reject issuer keys that do not chain to it.",
545
545
  "preconditions": [
546
- "verifier owns or can configure the set of trusted issuers"
546
+ "verifier_controls_trusted_issuers == true"
547
547
  ],
548
548
  "priority": 1,
549
549
  "for_signals": [
@@ -557,7 +557,7 @@
557
557
  "id": "enforce-revocation-on-accept",
558
558
  "description": "Resolve the status_list on the accept path and reject on a set status bit; treat a status fetch failure as fail-closed (not \"valid\").",
559
559
  "preconditions": [
560
- "credentials carry a status mechanism"
560
+ "credentials_carry_status_mechanism == true"
561
561
  ],
562
562
  "priority": 1,
563
563
  "for_signals": [
@@ -568,7 +568,7 @@
568
568
  "id": "bind-presentations-to-nonce-audience",
569
569
  "description": "Issue a fresh per-request nonce, require a Key-Binding JWT echoing that nonce + the verifier audience, and verify device-auth for mdoc; reject unbound presentations.",
570
570
  "preconditions": [
571
- "verifier controls the request/challenge"
571
+ "verifier_controls_request_challenge == true"
572
572
  ],
573
573
  "priority": 1,
574
574
  "for_signals": [
@@ -694,7 +694,7 @@
694
694
  "id": "policy-exception",
695
695
  "description": "Generate an auditor-ready policy exception when faster paths are blocked (legacy receiver cannot be patched within window, provider does not offer state-parameter enforcement, etc.) with compensating controls and time-bound risk acceptance.",
696
696
  "preconditions": [
697
- "remediation_paths[1..3] all blocked",
697
+ "prior_remediations_blocked == true",
698
698
  "ciso_acceptance_obtainable == true"
699
699
  ],
700
700
  "priority": 4,
@@ -953,6 +953,31 @@ function detect(playbookId, directiveId, agentSubmission = {}, runOpts = {}) {
953
953
 
954
954
  // --- phase 5: analyze ---
955
955
 
956
+ /**
957
+ * Mirror the FIRED detect indicators (verdict === 'hit') into a flat
958
+ * `{ <indicator-id>: true }` map for the escalation / feeds_into / precondition
959
+ * eval contexts. Indicator hits arrive from the collector / AI evidence path as
960
+ * `signal_overrides` (which detect() reads); the condition contexts spread
961
+ * `agentSubmission.signals`, NOT signal_overrides — so an escalation written as
962
+ * `<indicator-id> == true` (the catalog's canonical form) stayed false even when
963
+ * that indicator DETECTED, unless the operator redundantly re-submitted the id
964
+ * under `signals`. The standard `exceptd collect <playbook>` path never does
965
+ * that, so the entire indicator-gated escalation/chain layer was dead on the
966
+ * real evidence path. Mirroring fired indicators here binds the condition layer
967
+ * to the detect layer. Only verdict 'hit' (FP-checks satisfied) is mirrored — an
968
+ * 'inconclusive' indicator is unconfirmed and must not fire an escalation. The
969
+ * map is spread with the LOWEST precedence (before agentSignals, which is before
970
+ * the engine roots) so an operator can still override a specific id and the
971
+ * engine-computed values always win.
972
+ */
973
+ function firedIndicatorSignals(detectIndicators) {
974
+ const out = {};
975
+ for (const ind of (detectIndicators || [])) {
976
+ if (ind && ind.verdict === 'hit' && typeof ind.id === 'string') out[ind.id] = true;
977
+ }
978
+ return out;
979
+ }
980
+
956
981
  /**
957
982
  * RWEP composition + blast-radius scoring + theater check + framework gap
958
983
  * mapping + escalation evaluation. Inputs are the detect result + any
@@ -1490,7 +1515,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1490
1515
  findingShape = {};
1491
1516
  }
1492
1517
  for (const ec of an.escalation_criteria || []) {
1493
- if (evalCondition(ec.condition, { ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, compliance_theater_check: result.compliance_theater_check, jurisdiction_obligations: (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [], analyze: result, matched_cve: result.matched_cves || [],
1518
+ if (evalCondition(ec.condition, { ...firedIndicatorSignals(detectResult.indicators), ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, compliance_theater_check: result.compliance_theater_check, jurisdiction_obligations: (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [], analyze: result, matched_cve: result.matched_cves || [],
1494
1519
  // finding.* is two-sourced: the engine computes the CVE/severity-derived
1495
1520
  // keys (severity, rwep_adjusted, matched_cve_*, blast_radius_score,
1496
1521
  // active_exploitation, framework/control_id_first) via analyzeFindingShape,
@@ -1579,9 +1604,38 @@ function capSummary(s, max = 240) {
1579
1604
 
1580
1605
  function validate(playbookId, directiveId, analyzeResult, agentSignals = {}, runOpts = {}) {
1581
1606
  const playbook = runOpts._playbookCache || loadPlaybook(playbookId);
1582
- // Surface evalCondition regex errors raised here into the same
1583
- // run-wide accumulator that analyze() reads.
1584
- const evalCtx = runOpts._runErrors ? { ...agentSignals, _runErrors: runOpts._runErrors } : agentSignals;
1607
+ // Remediation-path preconditions are evaluated through the SAME evalCondition
1608
+ // the analyze/close phases use, so the precondition context must expose the
1609
+ // SAME engine-computed roots those phases thread in — otherwise a precondition
1610
+ // that gates on `rwep`, `finding.severity`, `matched_cve.*`,
1611
+ // `blast_radius_score`, or `theater_verdict` resolves null and can NEVER be
1612
+ // satisfied, so its remediation path is only ever reachable as the priority
1613
+ // fallback (kernel's `any matched_cve.vector matches 'userns|bpf|ptrace|kptr'`
1614
+ // hardening-compensation path was permanently unsatisfiable for this reason).
1615
+ // agentSignals spread FIRST so the engine-computed values WIN on collision (a
1616
+ // poisoning signals.rwep can't override the engine value) — mirrors the
1617
+ // escalation (analyze) and feeds_into (close) contexts. finding.* merges the
1618
+ // host-AI-asserted descriptive keys UNDER the engine shape with the same
1619
+ // !Array.isArray guard used there.
1620
+ const govern = (playbook.phases && playbook.phases.govern) || {};
1621
+ let findingShape = {};
1622
+ try { findingShape = analyzeFindingShape(analyzeResult || {}); } catch { findingShape = {}; }
1623
+ const evalCtx = {
1624
+ // Fired detect indicators mirrored in (lowest precedence) so a precondition
1625
+ // referencing an indicator id resolves on the collector path. See
1626
+ // firedIndicatorSignals.
1627
+ ...firedIndicatorSignals(analyzeResult && analyzeResult._detect_indicators),
1628
+ ...agentSignals,
1629
+ rwep: analyzeResult && analyzeResult.rwep ? analyzeResult.rwep.adjusted : undefined,
1630
+ blast_radius_score: analyzeResult ? analyzeResult.blast_radius_score : undefined,
1631
+ theater_verdict: analyzeResult && analyzeResult.compliance_theater_check ? analyzeResult.compliance_theater_check.verdict : undefined,
1632
+ compliance_theater_check: analyzeResult ? analyzeResult.compliance_theater_check : undefined,
1633
+ jurisdiction_obligations: govern.jurisdiction_obligations || [],
1634
+ matched_cve: (analyzeResult && analyzeResult.matched_cves) || [],
1635
+ analyze: analyzeResult,
1636
+ finding: { ...(agentSignals.finding && typeof agentSignals.finding === 'object' && !Array.isArray(agentSignals.finding) ? agentSignals.finding : {}), ...findingShape },
1637
+ ...(runOpts._runErrors ? { _runErrors: runOpts._runErrors } : {}),
1638
+ };
1585
1639
  const v = resolvedPhase(playbook, directiveId, 'validate');
1586
1640
 
1587
1641
  // Pick the highest-priority remediation_path whose preconditions are all
@@ -2058,6 +2112,11 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
2058
2112
  // feeds_into chaining — full analyze result is exposed so conditions can
2059
2113
  // reference `analyze.compliance_theater_check.verdict` etc.
2060
2114
  const feedsCtx = {
2115
+ // Fired detect indicators (verdict 'hit') mirrored in so a feeds_into
2116
+ // condition written as `<indicator-id> == true` resolves on the standard
2117
+ // collector path (signal_overrides), not only when the id is re-submitted
2118
+ // under signals. Lowest precedence — operator signals and engine values win.
2119
+ ...firedIndicatorSignals(analyzeResult && analyzeResult._detect_indicators),
2061
2120
  // Operator-submitted signals come FIRST so the engine-computed reserved
2062
2121
  // keys below always win — otherwise a submitted signals.rwep / finding /
2063
2122
  // analyze would override the engine value the feeds_into condition tests
@@ -4035,10 +4094,32 @@ function extractSubmissionForHash(sub) {
4035
4094
  // canonicalStringify can serialize them.
4036
4095
  const signals = {};
4037
4096
  for (const [k, v] of Object.entries(sub.signals)) {
4097
+ // Underscore-prefixed signal keys are runner-internal / output-rendering
4098
+ // directives, NOT operator evidence: `_bundle_formats` (the CLI sets it
4099
+ // from `--format sarif|openvex|csaf`) only chooses which bundle close()
4100
+ // renders — the analyzed posture is identical. Hashing it broke the
4101
+ // "same evidence → same evidence_hash" contract: two runs over identical
4102
+ // evidence that differ only in `--format` produced different digests and
4103
+ // a self-contradicting attest diff. Exclude every `_`-prefixed signal key
4104
+ // (matches the file-wide "underscore = internal" convention). vex_filter /
4105
+ // vex_fixed are deliberately NOT excluded — they DROP CVEs from
4106
+ // matched_cves and change the finding, so they are posture-affecting
4107
+ // evidence; excluding them would make reattest blind to a VEX-driven
4108
+ // posture change (a real drift hidden behind an unchanged hash).
4109
+ if (k.startsWith('_')) continue;
4038
4110
  if (v instanceof Set) signals[k] = Array.from(v).sort();
4039
4111
  else signals[k] = v;
4040
4112
  }
4041
- pick.signals = signals;
4113
+ // Omit an empty signals bag rather than recording `signals: {}`. A
4114
+ // submission whose ONLY signal was a `_`-prefixed render directive
4115
+ // (`{ _bundle_formats: [...] }`, injected by `ci`/`run --format`) reduces to
4116
+ // {} after the filter above; the baseline no-format submission carries no
4117
+ // `signals` key at all. Recording `{}` vs omitting it would hash identical
4118
+ // evidence as `{signals:{}}` vs no-signals and reintroduce the exact
4119
+ // --format-driven evidence_hash / session-id drift this exclusion exists to
4120
+ // prevent. Only attach signals when at least one operator-meaningful key
4121
+ // survived.
4122
+ if (Object.keys(signals).length > 0) pick.signals = signals;
4042
4123
  }
4043
4124
  if (sub.precondition_checks && typeof sub.precondition_checks === 'object') {
4044
4125
  pick.precondition_checks = sub.precondition_checks;
@@ -79,6 +79,70 @@ const fs = require('node:fs');
79
79
  const path = require('node:path');
80
80
  const process = require('node:process');
81
81
  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.
95
+ let _evalCondition = null;
96
+ try { ({ _evalCondition } = require('./playbook-runner')); } catch { /* warned at the gate */ }
97
+
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.
109
+ function _splitTopLevel(expr, sep) {
110
+ const parts = []; const needle = ' ' + sep + ' ';
111
+ let depth = 0, buf = '', i = 0, quote = null;
112
+ while (i < expr.length) {
113
+ const ch = expr[i];
114
+ if (quote) { if (ch === '\\' && i + 1 < expr.length) { buf += ch + expr[i + 1]; i += 2; continue; } if (ch === quote) quote = null; buf += ch; i++; continue; }
115
+ if (ch === "'" || ch === '"') { quote = ch; buf += ch; i++; continue; }
116
+ if (ch === '(') { depth++; buf += ch; i++; continue; }
117
+ if (ch === ')') { depth--; buf += ch; i++; continue; }
118
+ if (depth === 0 && expr.startsWith(needle, i)) { parts.push(buf.trim()); buf = ''; i += needle.length; continue; }
119
+ buf += ch; i++;
120
+ }
121
+ parts.push(buf.trim());
122
+ return parts;
123
+ }
124
+ function _stripWrappingParens(e) {
125
+ e = e.trim();
126
+ while (e.startsWith('(') && e.endsWith(')')) {
127
+ let d = 0, wraps = true;
128
+ for (let i = 0; i < e.length; i++) {
129
+ if (e[i] === '(') d++;
130
+ else if (e[i] === ')') { d--; if (d === 0 && i < e.length - 1) { wraps = false; break; } }
131
+ }
132
+ if (wraps) e = e.slice(1, -1).trim(); else break;
133
+ }
134
+ return e;
135
+ }
136
+ function atomizeCondition(cond) {
137
+ const atoms = [];
138
+ (function rec(e) {
139
+ e = _stripWrappingParens(e);
140
+ const ors = _splitTopLevel(e, 'OR'); if (ors.length > 1) { ors.forEach(rec); return; }
141
+ const ands = _splitTopLevel(e, 'AND'); if (ands.length > 1) { ands.forEach(rec); return; }
142
+ atoms.push(e);
143
+ })(cond);
144
+ return atoms;
145
+ }
82
146
 
83
147
  const REPO_ROOT = path.resolve(__dirname, '..');
84
148
  const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'playbook.schema.json');
@@ -550,6 +614,74 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
550
614
  }
551
615
  }
552
616
 
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).
638
+ 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
+ warn(
643
+ '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
+ );
645
+ }
646
+ const conditionParses = (cond) => {
647
+ if (typeof cond !== 'string' || !cond.trim()) return true;
648
+ 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
+ for (const atom of atomizeCondition(cond)) {
653
+ const runErrors = [];
654
+ try { _evalCondition(atom, { _runErrors: runErrors }, { _runErrors: runErrors }); }
655
+ catch { continue; } // an evaluator throw is a separate concern, not "unparseable"
656
+ if (runErrors.some((e) => e && e.kind === 'condition_unparsed')) return false;
657
+ }
658
+ return true;
659
+ };
660
+ for (const [i, ec] of (((phases.analyze || {}).escalation_criteria) || []).entries()) {
661
+ if (ec && typeof ec === 'object' && !conditionParses(ec.condition)) {
662
+ err(
663
+ `phases.analyze.escalation_criteria[${i}].condition: not parseable by the runner's evalCondition (${JSON.stringify(String(ec.condition).slice(0, 80))}) — it returns false for every input, so action "${ec.action || '?'}" never fires. Rewrite into the mini-language (<indicator-id> == true [AND/OR …], engine roots rwep/blast_radius_score/matched_cve/finding.*, membership IN [...], quantifier any/all, matches '…').`,
664
+ );
665
+ }
666
+ }
667
+ for (const [i, f] of (meta.feeds_into || []).entries()) {
668
+ if (f && typeof f === 'object' && !conditionParses(f.condition)) {
669
+ err(
670
+ `_meta.feeds_into[${i}].condition: not parseable by the runner's evalCondition (${JSON.stringify(String(f.condition).slice(0, 80))}) — the chain to "${f.playbook_id || '?'}" never fires. Rewrite into the mini-language.`,
671
+ );
672
+ }
673
+ }
674
+ for (const [i, rp] of (validatePhase.remediation_paths || []).entries()) {
675
+ if (!rp || typeof rp !== 'object' || !Array.isArray(rp.preconditions)) continue;
676
+ for (const [j, pc] of rp.preconditions.entries()) {
677
+ if (!conditionParses(pc)) {
678
+ err(
679
+ `phases.validate.remediation_paths[${i}] (${rp.id || 'unknown'}).preconditions[${j}]: not parseable by the runner's evalCondition (${JSON.stringify(String(pc).slice(0, 80))}) — it is never satisfiable, so the path can only be selected as the priority fallback. Rewrite into the mini-language (bare token → "<token> == true", prose gate → an operator-signalable "<snake_token> == true").`,
680
+ );
681
+ }
682
+ }
683
+ }
684
+
553
685
  // Air-gap completeness. When _meta.air_gap_mode is true the runner refuses
554
686
  // to touch the network, so every artifact whose source is a network call
555
687
  // (https://, http://, gh api, gh release, curl, wget, fetch) MUST carry a