@blamejs/exceptd-skills 0.18.6 → 0.18.8

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 (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +29 -5
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. package/vendor/blamejs/_PROVENANCE.json +4 -2
@@ -249,15 +249,22 @@ function preflight(playbook, runOpts = {}) {
249
249
 
250
250
  // 1. Currency gate
251
251
  const score = meta.threat_currency_score;
252
- if (score < 50 && !runOpts.forceStale) {
252
+ // A non-numeric score (absent / null / NaN from a malformed _meta) must HARD
253
+ // BLOCK, not slip through: `undefined < 50` is false, which would silently
254
+ // bypass the staleness gate on exactly the playbooks whose currency metadata
255
+ // is broken. Treat "no usable score" as the most-stale state.
256
+ const scoreUsable = typeof score === 'number' && !Number.isNaN(score);
257
+ if ((!scoreUsable || score < 50) && !runOpts.forceStale) {
253
258
  return {
254
259
  ok: false,
255
260
  blocked_by: 'currency',
256
- reason: `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`,
261
+ reason: scoreUsable
262
+ ? `threat_currency_score = ${score} (< 50). Hard-blocked. Pass forceStale=true to override.`
263
+ : `threat_currency_score is absent or non-numeric (${JSON.stringify(score)}). Hard-blocked — a playbook without a usable currency score is treated as stale. Fix _meta.threat_currency_score, or pass forceStale=true to override.`,
257
264
  issues
258
265
  };
259
266
  }
260
- if (score < 70) {
267
+ if (scoreUsable && score < 70) {
261
268
  issues.push({ kind: 'currency_warn', message: `threat_currency_score = ${score} (< 70). Threat model is stale — recommend running the skill-update-loop before relying on findings.` });
262
269
  }
263
270
 
@@ -357,19 +364,53 @@ function preflight(playbook, runOpts = {}) {
357
364
  return { ok: true, issues };
358
365
  }
359
366
 
360
- // lockDir lives at a stable global path so two CLI invocations from
367
+ // lockDir lives at a stable per-user path so two CLI invocations from
361
368
  // different working directories still share lock state for cross-process
362
369
  // mutex enforcement. A process.cwd()-relative dir would let invocations
363
370
  // from /tmp and from /home/user/project simultaneously each see an empty
364
- // locks dir and both run unchallenged. The path
365
- // keys on os.platform() so Windows/macOS/Linux locks live under separate
366
- // directories (avoids cross-platform stale-PID confusion when a host is
367
- // shared across OSes via networked FS). Override via EXCEPTD_LOCK_DIR for
368
- // container/CI scenarios that need an explicit shared location.
371
+ // locks dir and both run unchallenged.
372
+ //
373
+ // Resolution order (most-specific first):
374
+ // 1. EXCEPTD_LOCK_DIR explicit container/CI override
375
+ // 2. EXCEPTD_HOME || ~/.exceptd + /locks/<platform> per-user default
376
+ // 3. os.tmpdir()/exceptd-locks-<platform> last-resort fallback
377
+ // when the per-user home is non-writable (read-only home, restricted
378
+ // sandbox/CI runner).
379
+ //
380
+ // The per-user home (mirroring the orchestrator's watch.lock + attestation
381
+ // roots) is both the safer location — a per-user dir is not the
382
+ // world-writable shared OS tempdir a preplant/symlink attack targets — and
383
+ // the better fit for lockDir's stated goal: ~/.exceptd is per-user-stable
384
+ // across working directories, whereas the shared tmpdir is the weaker
385
+ // choice. The path keys on os.platform() so Windows/macOS/Linux locks live
386
+ // under separate directories (avoids cross-platform stale-PID confusion when
387
+ // a host is shared across OSes via networked FS).
388
+ function resolveLockDir() {
389
+ if (process.env.EXCEPTD_LOCK_DIR) return process.env.EXCEPTD_LOCK_DIR;
390
+ const home = process.env.EXCEPTD_HOME || (os.homedir() && path.join(os.homedir(), '.exceptd'));
391
+ if (home) {
392
+ const dir = path.join(home, 'locks', process.platform);
393
+ try {
394
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
395
+ // Probe writability with a marker; remove on success. A home that
396
+ // mkdirs but can't be written (read-only mount, restrictive ACL) must
397
+ // fall through to the tmpdir fallback rather than silently no-op every
398
+ // lock write.
399
+ const probe = path.join(dir, `.write-probe-${process.pid}`);
400
+ fs.writeFileSync(probe, '');
401
+ fs.unlinkSync(probe);
402
+ return dir;
403
+ } catch { /* home non-writable — fall through to tmpdir */ }
404
+ }
405
+ return path.join(os.tmpdir(), `exceptd-locks-${process.platform}`);
406
+ }
407
+
369
408
  function lockDir() {
370
- const dir = process.env.EXCEPTD_LOCK_DIR
371
- || path.join(os.tmpdir(), `exceptd-locks-${process.platform}`);
372
- try { fs.mkdirSync(dir, { recursive: true }); } catch {}
409
+ const dir = resolveLockDir();
410
+ // Owner-only (0700): the mutex lock files inside use O_EXCL ('wx'), but
411
+ // tightening the parent directory keeps another local user from listing or
412
+ // tampering with this user's lock set.
413
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch { /* exists / EACCES — non-fatal */ }
373
414
  return dir;
374
415
  }
375
416
 
@@ -388,14 +429,27 @@ function lockFilePath(playbookId) {
388
429
  // lifetime.
389
430
  const STALE_LOCK_MS = 30_000;
390
431
 
432
+ // Create the mutex lock file with an exclusive, owner-only descriptor
433
+ // (O_CREAT | O_EXCL | 0o600 via openSync 'wx'). The lock-file NAME is
434
+ // deliberately predictable — it IS the cross-process mutex, so every contender
435
+ // must agree on it; security comes from the exclusive create (a preplanted file
436
+ // or symlink makes the open throw EEXIST/EPERM, never a silent follow), the
437
+ // 0o700 lock directory, and the 0o600 mode — written through openSync so the
438
+ // secure-create primitive is explicit rather than a bare writeFileSync. Throws
439
+ // EEXIST when the lock is already held, which every caller already handles.
440
+ function writeLockFile(p, playbookId) {
441
+ const fd = fs.openSync(p, 'wx', 0o600);
442
+ try {
443
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2));
444
+ } finally {
445
+ fs.closeSync(fd);
446
+ }
447
+ }
448
+
391
449
  function acquireLock(playbookId) {
392
450
  const p = lockFilePath(playbookId);
393
451
  if (!p) return null;
394
- const writePayload = () => fs.writeFileSync(
395
- p,
396
- JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
397
- { flag: 'wx' }
398
- );
452
+ const writePayload = () => writeLockFile(p, playbookId);
399
453
  try {
400
454
  writePayload();
401
455
  return p;
@@ -458,9 +512,7 @@ function acquireLockDiagnostic(playbookId) {
458
512
  const p = lockFilePath(playbookId);
459
513
  if (!p) return { ok: false, reason: 'no_lock_path' };
460
514
  try {
461
- fs.writeFileSync(p,
462
- JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
463
- { flag: 'wx' });
515
+ writeLockFile(p, playbookId);
464
516
  return { ok: true, path: p };
465
517
  } catch (e) {
466
518
  if (e && (e.code === 'EEXIST' || e.code === 'EPERM')) {
@@ -476,9 +528,7 @@ function acquireLockDiagnostic(playbookId) {
476
528
  if (Number.isInteger(pid) && pid > 0 && pid !== process.pid && !pidAlive(pid)) {
477
529
  try { fs.unlinkSync(p); } catch {}
478
530
  try {
479
- fs.writeFileSync(p,
480
- JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
481
- { flag: 'wx' });
531
+ writeLockFile(p, playbookId);
482
532
  return { ok: true, path: p, reclaimed_from_pid: pid };
483
533
  } catch (e2) {
484
534
  return { ok: false, reason: 'reclaim_failed', error: e2.message, lock_path: p, holder_pid: pid };
@@ -494,9 +544,7 @@ function acquireLockDiagnostic(playbookId) {
494
544
  if (mtimeMs !== null && (Date.now() - mtimeMs) > STALE_LOCK_MS) {
495
545
  try { fs.unlinkSync(p); } catch {}
496
546
  try {
497
- fs.writeFileSync(p,
498
- JSON.stringify({ pid: process.pid, started_at: new Date().toISOString(), playbook: playbookId }, null, 2),
499
- { flag: 'wx' });
547
+ writeLockFile(p, playbookId);
500
548
  return { ok: true, path: p, reclaimed_self_stale_pid: true, prior_mtime_ms: mtimeMs };
501
549
  } catch (e3) {
502
550
  return { ok: false, reason: 'reclaim_failed', error: e3.message, lock_path: p, holder_pid: pid };
@@ -608,7 +656,12 @@ function look(playbookId, directiveId, runOpts = {}) {
608
656
  // Surface the air-gap alternative as the primary source when air_gap_mode
609
657
  // is active, so the agent doesn't accidentally hit the network.
610
658
  source: airGap && a.air_gap_alternative ? a.air_gap_alternative : a.source,
611
- _original_source: a.source
659
+ _original_source: a.source,
660
+ // In air-gap mode an artifact with NO air_gap_alternative silently keeps
661
+ // its original (possibly network-bound) source — defeating air-gap with no
662
+ // signal. Flag it so the agent treats the source as not-offline-verified
663
+ // and the gap is observable instead of a silent network fallback.
664
+ ...(airGap && !a.air_gap_alternative ? { air_gap_alternative_missing: true } : {}),
612
665
  })),
613
666
  collection_scope: l.collection_scope,
614
667
  environment_assumptions: l.environment_assumptions || [],
@@ -978,8 +1031,12 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
978
1031
  : [];
979
1032
  // VEX-fixed CVEs remain in matched/catalog arrays but get annotated
980
1033
  // with vex_status:'fixed' downstream so consumers see them as resolved.
1034
+ // Source from catalogBaselineCves (post-vexFilter survivors), NOT allCves: a
1035
+ // CVE the operator marked BOTH not_affected (dropped by vexFilter) AND fixed
1036
+ // is contradictory, and listing it as fixed while it's absent from
1037
+ // matched/baseline would have it appear in fixed_cves but nowhere else.
981
1038
  const vexFixedIds = vexFixed
982
- ? allCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
1039
+ ? catalogBaselineCves.filter(c => vexFixed.has(c.cve_id)).map(c => c.cve_id)
983
1040
  : [];
984
1041
 
985
1042
  // Build correlation map: cve_id -> array of "indicator_hit:<id>" / "signal:<id>" reasons.
@@ -1058,6 +1115,14 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1058
1115
  const vexStatus = (vexFixed && vexFixed.has(c.cve_id)) ? 'fixed' : null;
1059
1116
  return {
1060
1117
  cve_id: c.cve_id,
1118
+ // attack_class is the coarse chainable taxonomy (kernel-lpe /
1119
+ // mcp-supply-chain / ai-c2 / prompt-injection / container-escape / …)
1120
+ // the sbom -> deep-dive feeds_into rules quantify over
1121
+ // (`any matched_cve.attack_class == 'kernel-lpe'`). It comes from the
1122
+ // catalog entry's explicit attack_class only — no fuzzy inference from
1123
+ // the free-form `type`, so an unclassified CVE stays null and the chain
1124
+ // correctly does not fire (no false escalation) rather than misrouting.
1125
+ attack_class: c.entry?.attack_class ?? null,
1061
1126
  rwep: c.rwep_score,
1062
1127
  cvss_score: c.entry?.cvss_score ?? null,
1063
1128
  cvss_vector: c.entry?.cvss_vector ?? null,
@@ -1408,7 +1473,7 @@ function analyze(playbookId, directiveId, detectResult, agentSignals = {}, runOp
1408
1473
  findingShape = {};
1409
1474
  }
1410
1475
  for (const ec of an.escalation_criteria || []) {
1411
- if (evalCondition(ec.condition, { ...agentSignals, ...evalCtxRoot, rwep: adjustedRwep, blast_radius_score: blastRadiusScore, theater_verdict: theaterVerdict, analyze: result, finding: findingShape }, playbook)) {
1476
+ 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 || [], finding: findingShape }, playbook)) {
1412
1477
  escalations.push({ condition: ec.condition, action: ec.action, target_playbook: ec.target_playbook || null });
1413
1478
  }
1414
1479
  }
@@ -1695,8 +1760,16 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1695
1760
  };
1696
1761
  const enrichNotification = (na) => {
1697
1762
  const obligation = (g.jurisdiction_obligations || []).find(o =>
1763
+ o && typeof o === 'object' &&
1698
1764
  `${o.jurisdiction}/${o.regulation} ${o.window_hours}h` === na.obligation_ref
1699
1765
  );
1766
+ // A non-empty obligation_ref that resolves to nothing leaves the
1767
+ // jurisdiction / regulation / deadline fields null below — surface it as a
1768
+ // runtime_error so the unmatched ref is observable, not a silent null record.
1769
+ if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref &&
1770
+ Array.isArray(runOpts && runOpts._runErrors)) {
1771
+ pushRunError(runOpts._runErrors, { kind: 'unresolved_obligation_ref', obligation_ref: na.obligation_ref }, { dedupeKey: x => x.obligation_ref || '' });
1772
+ }
1700
1773
  // Thread runOpts + the engine-computed classification through so
1701
1774
  // computeClockStart can check operator_consent.explicit before
1702
1775
  // auto-stamping detect_confirmed, and so an engine-confirmed detection
@@ -1727,9 +1800,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1727
1800
  && autoStartEvent
1728
1801
  && eventReady
1729
1802
  && !(runOpts && runOpts.operator_consent && runOpts.operator_consent.explicit === true);
1730
- const deadline = obligation && clockValid
1803
+ // window_hours must be a finite number before it enters the deadline
1804
+ // arithmetic — a malformed obligation with an undefined/null/non-number
1805
+ // window_hours would otherwise compute `getTime() + NaN` and crash the
1806
+ // close phase at `new Date(NaN).toISOString()`. Runtime validation of the
1807
+ // playbook is not enforced, so guard here independently of the schema.
1808
+ const windowValid = obligation && typeof obligation.window_hours === 'number' && Number.isFinite(obligation.window_hours);
1809
+ const deadline = obligation && clockValid && windowValid
1731
1810
  ? new Date(clockStart.getTime() + obligation.window_hours * 3600 * 1000).toISOString()
1732
1811
  : 'pending_clock_start_event';
1812
+ // A notification_action whose obligation_ref was specified but resolves to
1813
+ // no obligation is an internally-inconsistent playbook (the schema requires
1814
+ // obligation_ref to name a real govern obligation). The unmatched ref is
1815
+ // already surfaced as a runtime_error above; drop the record rather than
1816
+ // emit a null-jurisdiction/regulation entry that pollutes the deadline list.
1817
+ if (!obligation && na && typeof na.obligation_ref === 'string' && na.obligation_ref) {
1818
+ return null;
1819
+ }
1733
1820
  return {
1734
1821
  ...na,
1735
1822
  // Carry obligation metadata forward so each notification entry is
@@ -1778,7 +1865,10 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1778
1865
  })(),
1779
1866
  };
1780
1867
  };
1781
- const notificationActions = (c.notification_actions || []).map(enrichNotification);
1868
+ // enrichNotification returns null for an unresolved specified obligation_ref
1869
+ // (already surfaced as a runtime_error); filter those out so the notification
1870
+ // list never carries a null-jurisdiction record.
1871
+ const notificationActions = (c.notification_actions || []).map(enrichNotification).filter(Boolean);
1782
1872
 
1783
1873
  // A govern obligation that declares a notification duty but has no
1784
1874
  // matching close.notification_actions entry would otherwise never surface
@@ -1790,15 +1880,27 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1790
1880
  // can tell it apart from a playbook-authored action.
1791
1881
  const coveredObligationRefs = new Set((c.notification_actions || []).map(na => na.obligation_ref));
1792
1882
  for (const o of (g.jurisdiction_obligations || [])) {
1883
+ if (!o || typeof o !== 'object') continue; // skip a null/malformed obligation rather than crash close() during synthesis
1793
1884
  if (!String(o.obligation || '').startsWith('notify')) continue;
1885
+ // A notify obligation with a non-number window_hours is malformed: it would
1886
+ // synthesize a "…/… undefinedh" ref and could not produce a real deadline.
1887
+ // Surface it as a runtime_error and skip synthesis rather than emit a bogus
1888
+ // record (the deadline guard in enrichNotification also prevents the crash).
1889
+ if (typeof o.window_hours !== 'number' || !Number.isFinite(o.window_hours)) {
1890
+ if (Array.isArray(runOpts && runOpts._runErrors)) {
1891
+ pushRunError(runOpts._runErrors, { kind: 'malformed_obligation_window_hours', obligation: `${o.jurisdiction}/${o.regulation}` }, { dedupeKey: x => x.obligation || '' });
1892
+ }
1893
+ continue;
1894
+ }
1794
1895
  const ref = `${o.jurisdiction}/${o.regulation} ${o.window_hours}h`;
1795
1896
  if (coveredObligationRefs.has(ref)) continue;
1796
- notificationActions.push(enrichNotification({
1897
+ const synthesized = enrichNotification({
1797
1898
  obligation_ref: ref,
1798
1899
  recipient: null,
1799
1900
  draft_notification: null,
1800
1901
  synthesized_from_obligation: true,
1801
- }));
1902
+ });
1903
+ if (synthesized) notificationActions.push(synthesized);
1802
1904
  }
1803
1905
 
1804
1906
  // exception_generation — evaluate trigger.
@@ -1932,7 +2034,37 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
1932
2034
  // and could suppress a legitimate downstream chain.
1933
2035
  ...agentSignals,
1934
2036
  rwep: analyzeResult.rwep?.adjusted,
1935
- theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 0 : 100,
2037
+ // Bare-token parity with the escalation context (analyze()): catalog
2038
+ // feeds_into conditions reference the unqualified tokens `blast_radius_score`
2039
+ // and `theater_verdict` (e.g. framework.json's feeds_into into sbom). Without
2040
+ // these top-level keys resolvePath returns null and `null >= 4` is false
2041
+ // regardless of the engine-computed blast radius, so the chain is dead. They
2042
+ // are spread AFTER ...agentSignals so the engine value wins over any
2043
+ // operator-submitted signals.blast_radius_score / signals.theater_verdict.
2044
+ blast_radius_score: analyzeResult.blast_radius_score,
2045
+ theater_verdict: analyzeResult.compliance_theater_check?.verdict,
2046
+ // Bare top-level `compliance_theater_check` so catalog conditions that
2047
+ // reference `compliance_theater_check.verdict` unqualified (framework.json's
2048
+ // feeds_into into sbom) resolve — without it resolvePath returns null and the
2049
+ // clause is dead regardless of the engine verdict. The `analyze.*` alias
2050
+ // below stays for conditions that use the qualified path.
2051
+ compliance_theater_check: analyzeResult.compliance_theater_check,
2052
+ // Govern-phase jurisdiction obligations so feeds_into conditions like
2053
+ // `… AND jurisdiction_obligations contains 'EU'` (framework → sbom) resolve.
2054
+ jurisdiction_obligations: (g && g.jurisdiction_obligations) || (playbook.phases && playbook.phases.govern && playbook.phases.govern.jurisdiction_obligations) || [],
2055
+ // theater_score follows lib/framework-gap.js's convention: HIGH = more
2056
+ // theater detected (worse). A 'theater' verdict (a gap exists) is the
2057
+ // concerning case, so it scores 100; a clear verdict scores 0. (Earlier
2058
+ // this was inverted, so a feeds_into condition like `theater_score >= 50`
2059
+ // would have failed to fire exactly when a gap was found.)
2060
+ theater_score: analyzeResult.compliance_theater_check?.verdict === 'theater' ? 100 : 0,
2061
+ // Top-level matched_cve array so the shipped sbom feeds_into quantifiers
2062
+ // (`any matched_cve.attack_class == 'kernel-lpe'`, … IN ['ai-c2', …]) re-root
2063
+ // at each matched CVE. Without it the quantifier head resolves null and the
2064
+ // sbom -> kernel/mcp/ai-api deep-dive chains stay dead even when a matched
2065
+ // CVE carries the attack_class. The `analyze.matched_cves` alias stays for
2066
+ // conditions that use the qualified path.
2067
+ matched_cve: analyzeResult.matched_cves || [],
1936
2068
  analyze: analyzeResult,
1937
2069
  validate: validateResult,
1938
2070
  finding: analyzeFindingShape(analyzeResult),
@@ -2397,11 +2529,12 @@ function sanitizeOperatorText(s) {
2397
2529
  // U+007F, U+0080-009F, private-use, unassigned), so the family pass is a
2398
2530
  // documented-intent superset removal and the result is identical to the
2399
2531
  // single \p{C} strip.
2400
- const familyStripped = normalised
2401
- .replace(codepointClass.BIDI_RE_G, '')
2402
- .replace(codepointClass.C0_CTRL_RE_G, '')
2403
- .replace(codepointClass.ZW_RE_G, '')
2404
- .replace(codepointClass.NULL_RE_G, '');
2532
+ const familyStripped = codepointClass.applyCharStripPolicies(normalised, {
2533
+ bidiPolicy: 'strip',
2534
+ controlPolicy: 'strip',
2535
+ zeroWidthPolicy: 'strip',
2536
+ nullBytePolicy: 'strip',
2537
+ });
2405
2538
  const stripped = familyStripped.replace(/\p{C}/gu, '');
2406
2539
  const trimmed = stripped.trim();
2407
2540
  if (trimmed.length === 0) return null;
@@ -3309,7 +3442,23 @@ function normalizeSubmission(submission, playbook) {
3309
3442
  }
3310
3443
  if (typeof val === "object" && val !== null) {
3311
3444
  const aid = knownArtifacts.has(key) ? key : (val.artifact || key);
3312
- out.artifacts[aid] = { value: val.value, captured: val.captured !== false };
3445
+ // Preserve every evidence-bearing key the observation carried, not just
3446
+ // `value`. An observation whose secret/path lives under a non-`value`
3447
+ // key (e.g. { path, matched, reason } — a natural collector shape) was
3448
+ // previously collapsed to { value: undefined, captured } here, silently
3449
+ // discarding the actual evidence. Two observations capturing DIFFERENT
3450
+ // secrets under path/matched then hashed and diffed byte-identical, so
3451
+ // `attest diff` reported a false "unchanged" and masked real drift.
3452
+ // The reserved control keys (artifact/indicator/result) drive
3453
+ // signal_overrides below and are intentionally not echoed as evidence.
3454
+ const { artifact: _a, indicator: _i, result: _r, captured: _c, value: _v, ...evidence } = val;
3455
+ const normalizedArtifact = { captured: val.captured !== false };
3456
+ if (val.value !== undefined) normalizedArtifact.value = val.value;
3457
+ for (const [ek, ev] of Object.entries(evidence)) {
3458
+ if (ek === "__proto__" || ek === "constructor" || ek === "prototype") continue;
3459
+ normalizedArtifact[ek] = ev;
3460
+ }
3461
+ out.artifacts[aid] = normalizedArtifact;
3313
3462
  if (val.indicator && val.result !== undefined) {
3314
3463
  const newVerdict = canonicalizeOutcome(val.result);
3315
3464
  if (out.signal_overrides[val.indicator] !== undefined && out._signal_origins[val.indicator] !== undefined) {
@@ -3861,6 +4010,15 @@ function extractSubmissionForHash(sub) {
3861
4010
  return pick;
3862
4011
  }
3863
4012
 
4013
+ // Identity fields a `contains`/`includes` membership test targets when the
4014
+ // array holds objects (e.g. jurisdiction_obligations records). Membership is
4015
+ // field-scoped to these so a non-identity field (clock_starts, obligation,
4016
+ // regulation, a free-form tag) that happens to equal the member cannot satisfy
4017
+ // the predicate. `jurisdiction` is the only field the catalog's object-array
4018
+ // `contains` conditions target today; the allowlist is the seam to extend if a
4019
+ // future condition deliberately matches a different identity field.
4020
+ const OBJECT_MEMBERSHIP_FIELDS = ['jurisdiction'];
4021
+
3864
4022
  function evalCondition(expr, ctx, playbook) {
3865
4023
  if (!expr) return false;
3866
4024
  expr = expr.trim();
@@ -3879,6 +4037,91 @@ function evalCondition(expr, ctx, playbook) {
3879
4037
  const andParts = splitAtTopLevel(expr, 'AND');
3880
4038
  if (andParts.length > 1) return andParts.every(s => evalCondition(s, ctx, playbook));
3881
4039
 
4040
+ // Quantifier prefix: catalog conditions write `any <path> <op> <value>`
4041
+ // (e.g. framework.json's feeds_into `any compliance_theater_check.verdict ==
4042
+ // 'theater' …`). An unhandled `any `/`all ` prefix is unparseable and falls
4043
+ // through to `false`, silently disabling the clause it gates. Strip the
4044
+ // quantifier and re-evaluate the inner comparison: when the LHS path resolves
4045
+ // to a scalar (the framework theater_verdict case) the quantifier is prose
4046
+ // emphasis and the scalar comparison is the intended test; when the LHS first
4047
+ // segment resolves to an array, apply the predicate existentially (`any`) /
4048
+ // universally (`all`) across its elements. The inner clause is only the leaf
4049
+ // comparison (the surrounding AND/OR has already been split off above).
4050
+ const quant = expr.match(/^(any|all)\s+(.+)$/);
4051
+ if (quant) {
4052
+ const [, kind, inner] = quant;
4053
+ // `any head.rest <op|keyword> …` → re-root the predicate at each element of
4054
+ // `head` and apply it existentially (`any`) / universally (`all`). The head
4055
+ // is the FIRST dotted-path token of the inner clause; what follows it (`==`,
4056
+ // `IN [...]`, `contains`, `matches /…/`, `>=`, …) is operator-agnostic — the
4057
+ // inner clause is re-evaluated whole against `{ …ctx, [head]: el }`, so every
4058
+ // operator the leaf parser already understands works under a quantifier.
4059
+ // Earlier this branch only re-rooted clauses whose operator was in the
4060
+ // comparison set (`>=|<=|==|=|<|>|!=`); `IN`/`contains`/`matches` were
4061
+ // omitted, so e.g. sbom.json's `any matched_cve.attack_class IN
4062
+ // ['ai-c2','prompt-injection']` (the sbom→ai-api feeds_into trigger) fell
4063
+ // through to a whole-ctx eval where `matched_cve.attack_class` resolved to
4064
+ // undefined on the array and the clause was permanently false, while the
4065
+ // sibling `== 'kernel-lpe'` quantifiers fired. Requiring a `.`-qualified head
4066
+ // keeps the bare-token form (`any <path>`) routed to the non-emptiness branch
4067
+ // below.
4068
+ const headMatch = inner.match(/^([A-Za-z_][\w-]*)(?:\.[A-Za-z_][\w-]*)+(?:[\s.[]|$)/);
4069
+ if (headMatch) {
4070
+ const head = headMatch[1];
4071
+ const arr = resolvePath(ctx, head);
4072
+ if (Array.isArray(arr)) {
4073
+ const test = el => evalCondition(inner, { ...ctx, [head]: el }, playbook);
4074
+ return kind === 'all' ? arr.length > 0 && arr.every(test) : arr.some(test);
4075
+ }
4076
+ }
4077
+ // Bare-quantifier non-emptiness form: `any <path>` / `all <path>` with no
4078
+ // comparison operator is a quantifier OVER the resolved collection itself —
4079
+ // the author's intent is "at least one X exists" (`any`) / "every X is
4080
+ // truthy" (`all`), i.e. a non-emptiness / existence test. sbom.json's
4081
+ // `any actively_exploited_match …` EU CRA Art.14 (24h) notify_legal
4082
+ // escalation is the canonical case. Without this, the inner bare token has
4083
+ // no operator, so no comparison branch parses it and it falls through to
4084
+ // condition_unparsed → false, silently disabling the escalation even when
4085
+ // the array holds entries. Match ONLY a pure dotted-path token (no operator,
4086
+ // keyword, bracket, or space); anything with a comparison/keyword still
4087
+ // routes to the prose fall-through below so a genuinely malformed inner
4088
+ // clause stays observable.
4089
+ if (/^[A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*$/.test(inner)) {
4090
+ const v = resolvePath(ctx, inner);
4091
+ if (Array.isArray(v)) {
4092
+ return kind === 'all' ? v.length > 0 && v.every(Boolean) : v.length > 0;
4093
+ }
4094
+ // Non-array scalar: existence/truthiness (null/undefined/0/'' → false).
4095
+ return !!v;
4096
+ }
4097
+ // Scalar (or unresolved-array) LHS: the quantifier is prose; evaluate the
4098
+ // bare inner comparison. If it still doesn't parse it falls through to the
4099
+ // condition_unparsed diagnostic below, so a genuinely malformed clause stays
4100
+ // observable rather than silently passing.
4101
+ return evalCondition(inner, ctx, playbook);
4102
+ }
4103
+
4104
+ // Membership clauses (`contains`/`includes`/`IN [...]`) parse fine but resolve
4105
+ // their LHS path to a collection. When that path does NOT exist in ctx (the
4106
+ // resolved value is null/undefined — an authoring typo in the LHS token, or a
4107
+ // wrong-shape ctx that never populated the collection), the branch returns a
4108
+ // silent `false` that disables whatever escalation / feeds_into the clause
4109
+ // gates with no signal. The clause PARSED, so condition_unparsed (a
4110
+ // parse-failure diagnostic) never fires. Surface a distinct, low-severity
4111
+ // condition_path_unresolved so an LHS-path typo is observable — deduped on the
4112
+ // condition string like condition_unparsed. A present-but-empty array (or any
4113
+ // present value that simply isn't a collection) is a LEGITIMATE false, not an
4114
+ // unresolved path, so it does NOT push: only a literally-absent path does.
4115
+ const pushPathUnresolved = () => {
4116
+ const target = (ctx && Array.isArray(ctx._runErrors)) ? ctx._runErrors
4117
+ : (playbook && Array.isArray(playbook._runErrors)) ? playbook._runErrors
4118
+ : null;
4119
+ if (target) {
4120
+ pushRunError(target, { kind: 'condition_path_unresolved', condition: String(expr).slice(0, 200) },
4121
+ { dedupeKey: x => x.condition || '' });
4122
+ }
4123
+ };
4124
+
3882
4125
  // "rwep >= 90". The LHS/RHS path tokens admit hyphens because signal and
3883
4126
  // indicator IDs are canonically hyphenated across the catalog (e.g.
3884
4127
  // `no-security-md`, `kver-in-affected-range`); a `\w`-only token silently
@@ -3921,15 +4164,79 @@ function evalCondition(expr, ctx, playbook) {
3921
4164
  // "scope.targets includes named_remote" — `contains` is accepted as a
3922
4165
  // synonym for `includes` (the catalog uses both); hyphenated paths + members
3923
4166
  // are admitted for the same reason as the comparison branch above.
3924
- m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+(?:includes|contains)\s+([\w-]+)$/);
4167
+ // The member may be a bare token OR a quoted string (the catalog's
4168
+ // `jurisdiction_obligations contains 'EU'` / `contains 'EU/EU CRA Art.14 24h'`
4169
+ // forms). When the array holds objects (jurisdiction_obligations are records
4170
+ // like `{ jurisdiction:'EU', regulation:'NIS2 Art.21', … }`), membership is
4171
+ // field-TARGETED: it matches an identity field of the obligation, not ANY
4172
+ // field value. An unscoped `Object.values(el).includes(member)` over-matched
4173
+ // — `contains 'EU'` would be satisfied by a non-jurisdiction field that
4174
+ // happened to equal 'EU' (e.g. a tag), and `contains 'detect_confirmed'`
4175
+ // would match the unrelated `clock_starts` field that holds that exact value
4176
+ // in the shipped obligations. Scoping to OBJECT_MEMBERSHIP_FIELDS keeps the
4177
+ // intended "the obligation is for jurisdiction X" semantic and prevents a
4178
+ // future field collision (or an operator-/agent-supplied obligations array)
4179
+ // from forcing a notify_legal escalation via a non-jurisdiction field.
4180
+ m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+(?:includes|contains)\s+(?:'([^']+)'|"([^"]+)"|([\w-]+))$/);
3925
4181
  if (m) {
4182
+ const member = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
3926
4183
  const arr = resolvePath(ctx, m[1]);
3927
- return Array.isArray(arr) && arr.includes(m[2]);
4184
+ if (!Array.isArray(arr)) {
4185
+ if (arr == null) pushPathUnresolved();
4186
+ return false;
4187
+ }
4188
+ return arr.some((el) =>
4189
+ el === member ||
4190
+ (el && typeof el === 'object' &&
4191
+ OBJECT_MEMBERSHIP_FIELDS.some((f) => el[f] === member))
4192
+ );
3928
4193
  }
3929
4194
 
3930
- // "matched_cve.vector matches /regex/"
3931
- m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+matches\s+\/(.+)\/$/);
4195
+ // "matched_cve.attack_class IN ['kernel-lpe', 'rce']" — membership against a
4196
+ // bracketed literal list; members may be quoted or bare. A scalar LHS must be
4197
+ // in the list; an array LHS must intersect it. Unhandled before, so every
4198
+ // `IN [...]` escalation/feeds_into atom fell through to a silent false.
4199
+ // The member list is split quote-aware: a naive `split(',')` is unaware of
4200
+ // quotes, so a quoted member that itself contains a comma (`'EU, US'`) would
4201
+ // be broken into two members (`EU` and `US`), neither of which equals the
4202
+ // author's intended whole member — the clause then evaluated false with no
4203
+ // diagnostic (the regex still matched the bracket, so condition_unparsed never
4204
+ // fired). splitInMembers walks the list tracking single/double quote state and
4205
+ // only splits on commas at quote-depth 0, then strips the surrounding quotes.
4206
+ // The CLOSING `]` is located quote-aware too: a `[^\]]*` capture stops at the
4207
+ // FIRST `]`, so a quoted member containing a literal `]` (`'a]b'`) truncated
4208
+ // the list early and left trailing text the `$` anchor couldn't match — the
4209
+ // whole clause then fell through to condition_unparsed for every input. The
4210
+ // matching bracket is now found at quote-depth 0 (an unquoted `]` is still the
4211
+ // terminator; a `]` inside a quoted member is part of that member). Trailing
4212
+ // text after the closing bracket, or an unterminated bracket, still fails to
4213
+ // match and surfaces as condition_unparsed.
4214
+ m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+IN\s+\[/);
3932
4215
  if (m) {
4216
+ const body = sliceInBracketBody(expr.slice(m[0].length));
4217
+ if (body !== null) {
4218
+ const members = splitInMembers(body);
4219
+ const lv = resolvePath(ctx, m[1]);
4220
+ if (Array.isArray(lv)) return lv.some((x) => members.includes(String(x)));
4221
+ // lv == null means the LHS path doesn't exist in ctx (typo / wrong-shape) —
4222
+ // surface it as condition_path_unresolved so the dead clause is observable,
4223
+ // mirroring the contains/includes branch. A present scalar that's simply not
4224
+ // in the member list is a legitimate false and pushes nothing.
4225
+ if (lv == null) { pushPathUnresolved(); return false; }
4226
+ return members.includes(String(lv));
4227
+ }
4228
+ // body === null → no quote-depth-0 closing `]` ends the string; fall through
4229
+ // to the condition_unparsed diagnostic so the malformed clause stays visible.
4230
+ }
4231
+
4232
+ // "matched_cve.vector matches /regex/" — both delimiters are accepted: the
4233
+ // slash form `matches /re/` and the quote form `matches 're'` / `matches "re"`.
4234
+ // The catalog authors both (mcp.json's feeds_into uses the quoted form), and a
4235
+ // delimiter-specific parser silently disabled whichever form it didn't match —
4236
+ // the same class as the hyphenated-token gap above.
4237
+ m = expr.match(/^([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)\s+matches\s+(?:\/(.+)\/|'([^']+)'|"([^"]+)")$/);
4238
+ if (m) {
4239
+ const pattern = m[2] !== undefined ? m[2] : (m[3] !== undefined ? m[3] : m[4]);
3933
4240
  const val = resolvePath(ctx, m[1]);
3934
4241
  if (typeof val !== 'string') return false;
3935
4242
  // An operator-supplied or playbook-supplied regex with a syntax bug
@@ -3939,9 +4246,9 @@ function evalCondition(expr, ctx, playbook) {
3939
4246
  // analyze() can surface analyze.runtime_errors[] without losing the
3940
4247
  // diagnostic.
3941
4248
  try {
3942
- return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2] comes from an Ed25519-signed catalog playbook condition (/…/), so the pattern cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
4249
+ return new RegExp(pattern, 'i').test(val); // allow:dynamic-regex — pattern comes from an Ed25519-signed catalog playbook condition (/…/ or '…'), so it cannot be attacker-controlled without breaking the signature; the try/catch covers construction-time syntax errors only (it does NOT defend against catastrophic backtracking — do not reuse this shape for operator-supplied patterns)
3943
4250
  } catch (e) {
3944
- const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
4251
+ const errorRec = { _regex_eval_error: { source: m[1], expr: pattern, message: e && e.message ? String(e.message) : String(e) } };
3945
4252
  // Two sites where ctx may carry an accumulator: runOpts._runErrors
3946
4253
  // (threaded from run()) or ctx._runErrors directly. Prefer the runOpts
3947
4254
  // form; fall back to ctx.
@@ -3993,9 +4300,23 @@ function resolvePath(obj, dot) {
3993
4300
  function splitAtTopLevel(expr, sep) {
3994
4301
  const parts = [];
3995
4302
  const needle = ' ' + sep + ' ';
3996
- let depth = 0, buf = '', i = 0;
4303
+ let depth = 0, buf = '', i = 0, quote = null;
3997
4304
  while (i < expr.length) {
3998
4305
  const ch = expr[i];
4306
+ // Inside a quoted string literal, parens and the ` AND `/` OR ` needle are
4307
+ // LITERAL text, not boolean structure. A regex member like `matches 'foo('`
4308
+ // carries an unbalanced `(` that — counted blindly — would leave depth=1 so
4309
+ // a real top-level OR/AND would never split at depth 0 (silently disabling
4310
+ // the disjunct/conjunct), and a member like `contains 'EU AND US'` would be
4311
+ // torn at the inner ` AND ` as if it were an operator. Track quote state and
4312
+ // skip both while inside a quote. An unescaped matching quote closes the
4313
+ // literal; `\'`/`\"` stay in. Mirrors splitInMembers' quote-aware walk.
4314
+ if (quote) {
4315
+ if (ch === '\\' && i + 1 < expr.length) { buf += ch + expr[i + 1]; i += 2; continue; }
4316
+ if (ch === quote) quote = null;
4317
+ buf += ch; i++; continue;
4318
+ }
4319
+ if (ch === "'" || ch === '"') { quote = ch; buf += ch; i++; continue; }
3999
4320
  if (ch === '(') { depth++; buf += ch; i++; continue; }
4000
4321
  if (ch === ')') { depth--; buf += ch; i++; continue; }
4001
4322
  if (depth === 0 && expr.startsWith(needle, i)) {
@@ -4011,18 +4332,90 @@ function splitAtTopLevel(expr, sep) {
4011
4332
  return parts;
4012
4333
  }
4013
4334
 
4335
+ /**
4336
+ * Quote-aware splitter for the body of an `IN [...]` member list. Splits on
4337
+ * commas that are OUTSIDE any quoted run, then strips the surrounding quotes and
4338
+ * trims each member. A naive `.split(',')` is quote-unaware, so a quoted member
4339
+ * that contains a comma (`'EU, US'`) would be torn into two members; this walks
4340
+ * the string tracking single/double quote state so a comma inside a quoted run
4341
+ * is treated as a literal part of that member. Bare (unquoted) members are
4342
+ * supported too — the catalog authors both forms (`'ai-c2', 'prompt-injection'`
4343
+ * and bare `kernel-lpe`). Empty members (e.g. a trailing comma) are dropped.
4344
+ */
4345
+ function splitInMembers(listStr) {
4346
+ const out = [];
4347
+ let buf = '';
4348
+ let quote = null;
4349
+ for (let i = 0; i < listStr.length; i++) {
4350
+ const ch = listStr[i];
4351
+ if (quote) {
4352
+ if (ch === quote) quote = null;
4353
+ else buf += ch;
4354
+ continue;
4355
+ }
4356
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
4357
+ if (ch === ',') { out.push(buf.trim()); buf = ''; continue; }
4358
+ buf += ch;
4359
+ }
4360
+ out.push(buf.trim());
4361
+ return out.filter((s) => s.length);
4362
+ }
4363
+
4364
+ /**
4365
+ * Locate the closing `]` of an `IN [...]` list quote-aware and return the body
4366
+ * between the (already-consumed) `[` and that `]`. `rest` is the text after the
4367
+ * opening bracket. The terminator is the first `]` encountered at quote-depth 0;
4368
+ * a `]` inside a single/double-quoted member (`'a]b'`) is part of the member, not
4369
+ * the terminator. A `[^\]]*` regex capture instead stops at the FIRST `]`, so a
4370
+ * quoted `]` truncated the list and left trailing text the `$` anchor rejected —
4371
+ * the clause then fell through to condition_unparsed for every input.
4372
+ *
4373
+ * Returns the body string on success, or `null` when the list is malformed:
4374
+ * unterminated (no quote-depth-0 `]`) or carrying non-whitespace text after the
4375
+ * closing bracket. `null` lets the caller fall through to the condition_unparsed
4376
+ * diagnostic so a malformed clause stays observable rather than passing silently.
4377
+ */
4378
+ function sliceInBracketBody(rest) {
4379
+ let quote = null;
4380
+ for (let i = 0; i < rest.length; i++) {
4381
+ const ch = rest[i];
4382
+ if (quote) { if (ch === quote) quote = null; continue; }
4383
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
4384
+ if (ch === ']') {
4385
+ // The bracket must be the final structural token: only whitespace may
4386
+ // follow. Anything else (e.g. `IN ['a'] AND …` reaching here, or stray
4387
+ // trailing text) is malformed for this leaf parser.
4388
+ return rest.slice(i + 1).trim() === '' ? rest.slice(0, i) : null;
4389
+ }
4390
+ }
4391
+ return null; // no quote-depth-0 closing bracket → unterminated list
4392
+ }
4393
+
4014
4394
  /**
4015
4395
  * Strip a balanced pair of outer parens, if and only if the very first and last
4016
4396
  * characters are matching parens at the same depth boundary. `(A) AND (B)` keeps
4017
- * its parens; `((A AND B))` peels one layer.
4397
+ * its parens; `((A AND B))` peels one layer. The depth scan is quote-aware: a
4398
+ * paren inside a quoted string literal (e.g. a regex member `matches '(a|b)'` or
4399
+ * an unbalanced `matches 'foo('`) is literal text, not grouping structure, so it
4400
+ * must not move the depth counter — otherwise a quoted `)` could make the outer
4401
+ * pair look unbalanced (skipping a legitimate strip) or an unbalanced quoted `(`
4402
+ * could make a non-wrapping pair look outer-spanning (stripping wrongly).
4018
4403
  */
4019
4404
  function stripOuterParens(expr) {
4020
4405
  while (expr.length >= 2 && expr[0] === '(' && expr[expr.length - 1] === ')') {
4021
4406
  let depth = 0;
4022
4407
  let outerMatches = true;
4408
+ let quote = null;
4023
4409
  for (let i = 0; i < expr.length - 1; i++) {
4024
- if (expr[i] === '(') depth++;
4025
- else if (expr[i] === ')') depth--;
4410
+ const ch = expr[i];
4411
+ if (quote) {
4412
+ if (ch === '\\') { i++; continue; }
4413
+ if (ch === quote) quote = null;
4414
+ continue;
4415
+ }
4416
+ if (ch === "'" || ch === '"') { quote = ch; continue; }
4417
+ if (ch === '(') depth++;
4418
+ else if (ch === ')') depth--;
4026
4419
  if (depth === 0 && i < expr.length - 1) { outerMatches = false; break; }
4027
4420
  }
4028
4421
  if (outerMatches) expr = expr.slice(1, -1).trim();