@blamejs/exceptd-skills 0.12.15 → 0.12.18

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.
package/bin/exceptd.js CHANGED
@@ -227,7 +227,8 @@ v0.12.0 canonical surface
227
227
  for latest published version + days behind
228
228
 
229
229
  ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
230
- 3 ran-but-no-evidence, 4 blocked (ok:false), 1 framework error.
230
+ 3 ran-but-no-evidence, 4 blocked (ok:false),
231
+ 5 jurisdiction clock started, 1 framework error.
231
232
  --all | --scope <type> | (auto-detect)
232
233
  --max-rwep <n> cap below playbook default
233
234
  --block-on-jurisdiction-clock
@@ -414,8 +415,21 @@ function main() {
414
415
  if (cmd === "refresh" && (rest.includes("--no-network") || rest.includes("--prefetch"))) {
415
416
  // v0.11.14 (#129): --prefetch is the operator-facing name for the
416
417
  // cache-population path. --no-network retained as alias for back-compat.
418
+ //
419
+ // v0.12.16: BUT — `refresh --no-network` previously stripped BOTH flags
420
+ // before invoking prefetch.js, leaving prefetch in network-fetching
421
+ // (default) mode. The operator's "do not touch the network" intent was
422
+ // lost in dispatch. Ubuntu CI passed because cached data was warm;
423
+ // Windows + macOS CI runners with cold caches hit 30s test timeout
424
+ // attempting 47 real fetches. Preserve `--no-network` when the operator
425
+ // explicitly supplied it; strip only `--prefetch` (the alias).
417
426
  effectiveCmd = "prefetch";
418
- effectiveRest = rest.filter(a => a !== "--no-network" && a !== "--prefetch");
427
+ const wantedNoNetwork = rest.includes("--no-network");
428
+ effectiveRest = rest.filter(a => a !== "--prefetch");
429
+ if (wantedNoNetwork && !effectiveRest.includes("--no-network")) {
430
+ // Already preserved; no-op. But explicit so a future filter regression
431
+ // is visible.
432
+ }
419
433
  } else if (cmd === "refresh" && rest.includes("--indexes-only")) {
420
434
  effectiveCmd = "build-indexes";
421
435
  effectiveRest = rest.filter(a => a !== "--indexes-only");
@@ -578,6 +592,55 @@ function loadRunner() {
578
592
  return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
579
593
  }
580
594
 
595
+ /**
596
+ * F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
597
+ * OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
598
+ * anything else, which means an operator who passes SARIF / SBOM / CSAF /
599
+ * advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
600
+ * at the CLI layer so the operator finds out at flag parse time.
601
+ *
602
+ * Returns { ok, detected, top_level_keys[] }. `detected` is one of:
603
+ * "cyclonedx-vex" | "openvex" | "not-vex"
604
+ */
605
+ function detectVexShape(doc) {
606
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
607
+ return { ok: false, detected: "not-an-object", top_level_keys: [] };
608
+ }
609
+ const keys = Object.keys(doc);
610
+ // CycloneDX VEX: bomFormat==="CycloneDX" + vulnerabilities[] is the
611
+ // canonical shape; CycloneDX 1.4+ also allows a standalone vulnerabilities
612
+ // document where entries carry analysis.state. Accept either when the
613
+ // entries look vex-shaped (have id/bom_ref/analysis).
614
+ if (Array.isArray(doc.vulnerabilities)) {
615
+ const isBom = doc.bomFormat === "CycloneDX";
616
+ const entriesLookVex = doc.vulnerabilities.length === 0
617
+ || doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
618
+ if (isBom || entriesLookVex) {
619
+ return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
620
+ }
621
+ }
622
+ // OpenVEX: @context starts with https://openvex.dev AND statements[]
623
+ const ctx = doc["@context"];
624
+ const ctxStr = Array.isArray(ctx) ? ctx[0] : ctx;
625
+ if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev") && Array.isArray(doc.statements)) {
626
+ return { ok: true, detected: "openvex", top_level_keys: keys };
627
+ }
628
+ // Common false-positive shapes — give the operator a hint.
629
+ if (Array.isArray(doc.runs) && doc.$schema && String(doc.$schema).includes("sarif")) {
630
+ return { ok: false, detected: "sarif-not-vex", top_level_keys: keys };
631
+ }
632
+ if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
633
+ return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
634
+ }
635
+ if (doc.bomFormat === "CycloneDX" && !Array.isArray(doc.vulnerabilities)) {
636
+ return { ok: false, detected: "cyclonedx-sbom-without-vulnerabilities", top_level_keys: keys };
637
+ }
638
+ if (Array.isArray(doc.statements) && !ctxStr) {
639
+ return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
640
+ }
641
+ return { ok: false, detected: "unrecognized", top_level_keys: keys };
642
+ }
643
+
581
644
  function firstDirectiveId(runner, playbookId) {
582
645
  const pb = runner.loadPlaybook(playbookId);
583
646
  if (!pb.directives || !pb.directives.length) {
@@ -598,6 +661,7 @@ function dispatchPlaybook(cmd, argv) {
598
661
  bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
599
662
  "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
600
663
  "force-overwrite", "no-stream", "block-on-jurisdiction-clock",
664
+ "force-replay",
601
665
  "json-stdout-only", "fix", "human", "json", "strict-preconditions",
602
666
  // v0.12.9: doctor --shipped-tarball runs the verify-shipped-tarball
603
667
  // gate alongside --signatures. doctor --registry-check + --signatures
@@ -680,8 +744,42 @@ function dispatchPlaybook(cmd, argv) {
680
744
  }
681
745
  // Multi-operator teams need attestations bound to a specific human or
682
746
  // service identity. --operator <name> persists into the attestation file
683
- // for audit-trail accountability. Free-form string; no validation.
684
- if (args.operator) runOpts.operator = args.operator;
747
+ // for audit-trail accountability.
748
+ //
749
+ // F9: validate the input. Pre-fix the value flowed into runOpts unchanged,
750
+ // so an operator could inject newlines / control chars / arbitrary length
751
+ // into attestation export output (multi-line "operator:" key/value pairs
752
+ // are a forgery surface — a forged second line could look like a separate
753
+ // attestation field to a naive parser). Now: strip ASCII control chars
754
+ // (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
755
+ if (args.operator !== undefined) {
756
+ if (typeof args.operator !== "string") {
757
+ return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
758
+ }
759
+ // eslint-disable-next-line no-control-regex
760
+ if (/[\x00-\x1F\x7F]/.test(args.operator)) {
761
+ return emitError(
762
+ "run: --operator contains ASCII control characters (newline, tab, NUL, etc.). Refusing — these would corrupt attestation export shape and enable forgery via multi-line injection.",
763
+ { provided_length: args.operator.length },
764
+ pretty
765
+ );
766
+ }
767
+ if (args.operator.length > 256) {
768
+ return emitError(
769
+ `run: --operator too long: ${args.operator.length} chars (limit 256). Use a stable identifier (email, service-account name) — not a free-form description.`,
770
+ { provided_length: args.operator.length },
771
+ pretty
772
+ );
773
+ }
774
+ if (args.operator.trim().length === 0) {
775
+ return emitError(
776
+ "run: --operator is empty or whitespace-only. Pass a meaningful identifier or omit the flag.",
777
+ null,
778
+ pretty
779
+ );
780
+ }
781
+ runOpts.operator = args.operator;
782
+ }
685
783
  // --ack: operator acknowledges the jurisdiction obligations surfaced by
686
784
  // govern. Captured in attestation; downstream tooling can check whether
687
785
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
@@ -1026,6 +1124,20 @@ Flags:
1026
1124
  Stdin event grammar (one JSON object per line):
1027
1125
  {"event":"evidence","payload":{"observations":{},"verdict":{}}}
1028
1126
 
1127
+ Stdin acceptance contract (Audit L F22):
1128
+ In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
1129
+ parseable {"event":"evidence","payload":{...}} line. That line wins:
1130
+ subsequent evidence events on the same run are ignored (the handler
1131
+ marks itself \`handled\` and refuses re-entry). Non-evidence chatter
1132
+ (status updates, the host AI's own progress events) is silently
1133
+ ignored — the host can interleave its own JSON events without
1134
+ triggering a phase transition. Invalid JSON on any line exits 1 with
1135
+ an {"event":"error","reason":"invalid JSON on stdin: ..."} frame.
1136
+
1137
+ If the host needs to send multiple evidence batches, spawn a separate
1138
+ ai-run per batch (each produces an independent session_id). Use
1139
+ --no-stream + --evidence <file> for single-shot single-batch runs.
1140
+
1029
1141
  Emits phases: govern → direct → look → await_evidence → detect → analyze
1030
1142
  → validate → close, then {"event":"done","ok":true,"session_id":"..."}.
1031
1143
  Errors emit {"event":"error","reason":"..."} and exit non-zero.`,
@@ -1077,8 +1189,11 @@ Exit codes:
1077
1189
  3 Ran-but-no-evidence Every result was inconclusive AND no evidence was
1078
1190
  submitted (visibility gap — CI should fail loud).
1079
1191
  4 Blocked Result returned ok:false (preflight halt, missing
1080
- preconditions with on_fail=halt, etc.) OR
1081
- --block-on-jurisdiction-clock fired.
1192
+ preconditions with on_fail=halt, etc.).
1193
+ 5 CLOCK_STARTED --block-on-jurisdiction-clock fired: at least one
1194
+ close.notification_actions entry started a
1195
+ regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
1196
+ etc.) and the operator has not acked.
1082
1197
 
1083
1198
  Output: verb, session_id, playbooks_run, summary{total, detected,
1084
1199
  max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
@@ -1645,13 +1760,32 @@ function cmdRun(runner, args, runOpts, pretty) {
1645
1760
  // --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
1646
1761
  // CVE ID set through to analyze() so matched_cves drops them.
1647
1762
  if (args.vex) {
1763
+ let vexDoc;
1764
+ try {
1765
+ vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
1766
+ } catch (e) {
1767
+ return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
1768
+ }
1769
+ // F5: validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
1770
+ // The runner tolerantly returns Set(0) for anything that's not CycloneDX
1771
+ // or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
1772
+ // advisory by mistake got ZERO filter applied and ZERO feedback. Now:
1773
+ // reject with a clear error naming the detected shape.
1774
+ const shape = detectVexShape(vexDoc);
1775
+ if (!shape.ok) {
1776
+ return emitError(
1777
+ `run: --vex file doesn't look like CycloneDX or OpenVEX. Detected shape: ${shape.detected}. ` +
1778
+ `Expected CycloneDX VEX (bomFormat:"CycloneDX" + vulnerabilities[]) or OpenVEX (@context starting "https://openvex.dev" + statements[]).`,
1779
+ { provided_path: args.vex, top_level_keys: shape.top_level_keys },
1780
+ pretty
1781
+ );
1782
+ }
1648
1783
  try {
1649
- const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
1650
1784
  const vexSet = runner.vexFilterFromDoc(vexDoc);
1651
1785
  submission.signals = submission.signals || {};
1652
1786
  submission.signals.vex_filter = [...vexSet];
1653
1787
  } catch (e) {
1654
- return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
1788
+ return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
1655
1789
  }
1656
1790
  }
1657
1791
 
@@ -1736,9 +1870,14 @@ function cmdRun(runner, args, runOpts, pretty) {
1736
1870
  }
1737
1871
 
1738
1872
  if (result && result.ok === false) {
1739
- // v0.12.14: exitCode + return; matches the emitError class fix.
1873
+ // F19: align preflight-halt exit code between `run --ci` and `ci`.
1874
+ // Pre-fix `run --ci` exited 1 (FRAMEWORK_ERROR) while `ci` on the same
1875
+ // halt exited 4 (BLOCKED). Now both use 4 when --ci is in effect, so
1876
+ // operators can wire one set of exit-code expectations regardless of
1877
+ // which verb they call. Without --ci the legacy exit 1 is preserved
1878
+ // (ok:false bodies are framework signals when no CI gating is asked for).
1740
1879
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
1741
- process.exitCode = 1;
1880
+ process.exitCode = args.ci ? 4 : 1;
1742
1881
  return;
1743
1882
  }
1744
1883
 
@@ -1788,6 +1927,26 @@ function cmdRun(runner, args, runOpts, pretty) {
1788
1927
  }
1789
1928
  }
1790
1929
 
1930
+ // --block-on-jurisdiction-clock (F3): the flag was registered + documented on
1931
+ // `run --help` but only honored on cmdCi. Pre-fix, `exceptd run mcp
1932
+ // --block-on-jurisdiction-clock` exited 0 even when an NIS2 24h clock had
1933
+ // started. Now: when ANY close.notification_actions entry has a started
1934
+ // clock that the operator hasn't acked, exit 5 (CLOCK_STARTED) with a
1935
+ // stderr line naming the obligations. Mirrors cmdCi semantics.
1936
+ if (args["block-on-jurisdiction-clock"] && result && result.phases) {
1937
+ const startedClocks = (result.phases?.close?.notification_actions || [])
1938
+ .filter(n => n && n.clock_started_at != null && n.clock_pending_ack !== true);
1939
+ if (startedClocks.length > 0) {
1940
+ const refs = startedClocks
1941
+ .map(n => `${n.obligation_ref || n.jurisdiction || "?"}@${n.clock_started_at}`)
1942
+ .join("; ");
1943
+ process.stderr.write(`[exceptd run --block-on-jurisdiction-clock] CLOCK_STARTED: ${startedClocks.length} jurisdiction clock(s) running and unacked: ${refs}. Exit 5.\n`);
1944
+ emit(result, pretty);
1945
+ process.exitCode = 5;
1946
+ return;
1947
+ }
1948
+ }
1949
+
1791
1950
  // --ci: machine-readable verdict for CI gates.
1792
1951
  //
1793
1952
  // The detect phase classification is the host-specific signal — "is THIS
@@ -1924,6 +2083,22 @@ function cmdRun(runner, args, runOpts, pretty) {
1924
2083
  const top = rwep?.threshold?.escalate ?? "n/a";
1925
2084
  const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
1926
2085
  lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
2086
+ // F11: surface --diff-from-latest verdict in the human renderer. Pre-fix
2087
+ // operators had to add --json to see whether the run drifted from the
2088
+ // previous attestation. Now one summary line follows the classification.
2089
+ // - unchanged: same evidence_hash as prior → reassuring single line.
2090
+ // - drifted: evidence differs → loud DRIFTED marker.
2091
+ // - no_prior_attestation_for_playbook: no line — don't clutter the
2092
+ // output when there is nothing to compare against.
2093
+ if (obj.diff_from_latest) {
2094
+ const dfl = obj.diff_from_latest;
2095
+ if (dfl.status === "unchanged") {
2096
+ lines.push(`> drift vs prior: unchanged (same evidence_hash as session ${dfl.prior_session_id})`);
2097
+ } else if (dfl.status === "drifted") {
2098
+ lines.push(`> drift vs prior: DRIFTED — evidence_hash differs from session ${dfl.prior_session_id}`);
2099
+ }
2100
+ // no_prior_attestation_for_playbook intentionally produces no line.
2101
+ }
1927
2102
  const cves = obj.phases?.analyze?.matched_cves || [];
1928
2103
  const baseline = obj.phases?.analyze?.catalog_baseline_cves || [];
1929
2104
  if (cves.length) {
@@ -1987,6 +2162,57 @@ function cmdRun(runner, args, runOpts, pretty) {
1987
2162
  * Falls back to running every playbook with empty evidence (engine returns
1988
2163
  * inconclusive findings + visibility gaps) when no --evidence is given.
1989
2164
  */
2165
+ /**
2166
+ * F13: collapse per-playbook notification_actions into a deduped rollup.
2167
+ * Multi-playbook runs frequently surface the same jurisdiction clock from
2168
+ * 5-10 contributing playbooks (every EU-touching playbook starts a fresh
2169
+ * NIS2 Art.23 24h clock). Operators were drafting one notification per
2170
+ * entry instead of one per (jurisdiction, regulation, obligation, window).
2171
+ * Key tuple stays additive — every contributor playbook id lands in
2172
+ * `triggered_by_playbooks[]` — and earliest clock_started_at + deadline
2173
+ * win so the strictest deadline is what an operator sees.
2174
+ */
2175
+ function buildJurisdictionClockRollup(results) {
2176
+ const m = new Map();
2177
+ for (const r of results || []) {
2178
+ if (!r || !r.phases) continue;
2179
+ const actions = r.phases?.close?.notification_actions || [];
2180
+ for (const n of actions) {
2181
+ if (!n || n.clock_started_at == null) continue;
2182
+ const key = [
2183
+ n.jurisdiction || "?",
2184
+ n.regulation || "?",
2185
+ n.obligation_ref || "?",
2186
+ String(n.window_hours ?? "?"),
2187
+ ].join("::");
2188
+ const existing = m.get(key);
2189
+ if (existing) {
2190
+ if (!existing.triggered_by_playbooks.includes(r.playbook_id)) {
2191
+ existing.triggered_by_playbooks.push(r.playbook_id);
2192
+ }
2193
+ // Strictest (earliest) clock_started_at + deadline win.
2194
+ if ((n.clock_started_at || "") < (existing.clock_started_at || "")) {
2195
+ existing.clock_started_at = n.clock_started_at;
2196
+ }
2197
+ if (n.deadline && (!existing.deadline || n.deadline < existing.deadline)) {
2198
+ existing.deadline = n.deadline;
2199
+ }
2200
+ } else {
2201
+ m.set(key, {
2202
+ jurisdiction: n.jurisdiction || null,
2203
+ regulation: n.regulation || null,
2204
+ obligation_ref: n.obligation_ref || null,
2205
+ window_hours: n.window_hours ?? null,
2206
+ clock_started_at: n.clock_started_at,
2207
+ deadline: n.deadline || null,
2208
+ triggered_by_playbooks: [r.playbook_id],
2209
+ });
2210
+ }
2211
+ }
2212
+ }
2213
+ return [...m.values()];
2214
+ }
2215
+
1990
2216
  function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
1991
2217
  const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
1992
2218
  runOpts.session_id = sessionId;
@@ -2071,6 +2297,16 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2071
2297
  results.push(result);
2072
2298
  }
2073
2299
 
2300
+ // F13: dedupe jurisdiction-clock notification actions across all playbook
2301
+ // results into a single rollup. Pre-fix a 13-playbook multi-run with 8
2302
+ // contributors of "EU NIS2 Art.23 24h" produced 8 separate entries, so
2303
+ // operators drafted 8 NIS2 notifications when one was sufficient. Per-
2304
+ // playbook entries are preserved on individual results; this rollup is
2305
+ // additive — keyed on (jurisdiction, regulation, obligation_ref,
2306
+ // window_hours) — with a triggered_by_playbooks[] list so operators see
2307
+ // which playbooks contributed.
2308
+ const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
2309
+
2074
2310
  emit({
2075
2311
  ok: results.every(r => r.ok !== false),
2076
2312
  session_id: sessionId,
@@ -2084,6 +2320,7 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2084
2320
  detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
2085
2321
  inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
2086
2322
  },
2323
+ jurisdiction_clock_rollup: jurisdictionClockRollup,
2087
2324
  results,
2088
2325
  }, pretty);
2089
2326
  // v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
@@ -2099,6 +2336,13 @@ function cmdIngest(runner, args, runOpts, pretty) {
2099
2336
  // `ingest` matches the AGENTS.md ingest contract. The submission JSON may
2100
2337
  // carry playbook_id + directive_id; --domain/--directive flags override.
2101
2338
  let submission = {};
2339
+ // F4: auto-detect piped stdin (parity with cmdRun). Without this,
2340
+ // `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
2341
+ // because args.evidence stayed undefined and the routing JSON never got
2342
+ // read. Mirrors the cmdRun behavior at line 1614.
2343
+ if (!args.evidence && process.stdin.isTTY === false) {
2344
+ args.evidence = "-";
2345
+ }
2102
2346
  if (args.evidence) {
2103
2347
  try {
2104
2348
  submission = readEvidence(args.evidence);
@@ -2459,7 +2703,64 @@ function walkAttestationDir(root, opts, candidates) {
2459
2703
  }
2460
2704
  }
2461
2705
 
2706
+ /**
2707
+ * F10: factored Ed25519-sidecar verification used by both `attest verify`
2708
+ * and `reattest`. Returns { file, signed, verified, reason } for a given
2709
+ * attestation file path.
2710
+ *
2711
+ * Pre-fix, cmdReattest read attestation.json via JSON.parse with no
2712
+ * authenticity check. A tampered attestation was silently consumed and the
2713
+ * drift verdict was computed against forged input. Now cmdReattest calls
2714
+ * this and refuses on verify-fail unless --force-replay is set.
2715
+ */
2716
+ function verifyAttestationSidecar(attFile) {
2717
+ const crypto = require("crypto");
2718
+ const sigPath = attFile + ".sig";
2719
+ const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2720
+ const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
2721
+ if (!fs.existsSync(sigPath)) {
2722
+ return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
2723
+ }
2724
+ let sigDoc;
2725
+ try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
2726
+ catch (e) { return { file: attFile, signed: false, verified: false, reason: `sidecar parse error: ${e.message}` }; }
2727
+ if (sigDoc.algorithm === "unsigned") {
2728
+ return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
2729
+ }
2730
+ if (!pubKey) {
2731
+ return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
2732
+ }
2733
+ let content;
2734
+ try { content = fs.readFileSync(attFile, "utf8"); }
2735
+ catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
2736
+ try {
2737
+ const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
2738
+ key: pubKey, dsaEncoding: "ieee-p1363",
2739
+ }, Buffer.from(sigDoc.signature_base64, "base64"));
2740
+ return {
2741
+ file: attFile,
2742
+ signed: true,
2743
+ verified: !!ok,
2744
+ reason: ok ? "Ed25519 signature valid" : "Ed25519 signature INVALID — possible post-hoc tampering",
2745
+ };
2746
+ } catch (e) {
2747
+ return { file: attFile, signed: true, verified: false, reason: `verify error: ${e.message}` };
2748
+ }
2749
+ }
2750
+
2462
2751
  function cmdReattest(runner, args, runOpts, pretty) {
2752
+ // F29: --since ISO-8601 validation parity with `attest list --since`
2753
+ // (already fixed in v0.12.12). Pre-fix, an invalid date silently passed
2754
+ // through to walkAttestationDir, where the lexical comparison either
2755
+ // matched all or none unpredictably.
2756
+ if (args.since != null) {
2757
+ if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
2758
+ return emitError(
2759
+ `reattest: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
2760
+ null, pretty
2761
+ );
2762
+ }
2763
+ }
2463
2764
  // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
2464
2765
  // without requiring the operator to know the session-id.
2465
2766
  let sessionId = args._[0];
@@ -2479,6 +2780,37 @@ function cmdReattest(runner, args, runOpts, pretty) {
2479
2780
  if (!fs.existsSync(attFile)) {
2480
2781
  return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
2481
2782
  }
2783
+
2784
+ // F10: verify the .sig sidecar BEFORE consuming the prior attestation.
2785
+ // Pre-fix, a tampered attestation.json was silently parsed and the drift
2786
+ // verdict was computed against forged input. Now: refuse on verify-fail
2787
+ // with exit 6 (TAMPERED) unless --force-replay is explicitly set.
2788
+ // Unsigned attestations (no private key was available at run time) emit
2789
+ // a stderr warning but proceed — that's an operator config issue, not
2790
+ // tampering. `verified === false && signed === true` is the real tamper
2791
+ // signal.
2792
+ const verify = verifyAttestationSidecar(attFile);
2793
+ if (verify.signed && !verify.verified && !args["force-replay"]) {
2794
+ process.stderr.write(`[exceptd reattest] TAMPERED: attestation at ${attFile} failed Ed25519 verification (${verify.reason}). Refusing to replay against forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
2795
+ const body = {
2796
+ ok: false,
2797
+ error: `reattest: prior attestation failed signature verification — refusing to replay`,
2798
+ verb: "reattest",
2799
+ session_id: sessionId,
2800
+ attestation_file: attFile,
2801
+ sidecar_verify: verify,
2802
+ hint: "If you have inspected the attestation and the divergence is benign (e.g. you re-signed manually), pass --force-replay.",
2803
+ };
2804
+ process.stderr.write(JSON.stringify(body) + "\n");
2805
+ process.exitCode = 6;
2806
+ return;
2807
+ }
2808
+ if (verify.signed && !verify.verified && args["force-replay"]) {
2809
+ process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding failed signature verification on ${attFile} (${verify.reason}). The replay output records sidecar_verify so the override is audit-visible.\n`);
2810
+ } else if (!verify.signed && verify.reason !== "no .sig sidecar") {
2811
+ process.stderr.write(`[exceptd reattest] NOTE: attestation at ${attFile} has no Ed25519 signature (${verify.reason}). Proceeding — unsigned attestations are an operator config issue, not tamper evidence.\n`);
2812
+ }
2813
+
2482
2814
  let prior;
2483
2815
  try {
2484
2816
  prior = JSON.parse(fs.readFileSync(attFile, "utf8"));
@@ -2544,6 +2876,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
2544
2876
  replayed_at: new Date().toISOString(),
2545
2877
  replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
2546
2878
  replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
2879
+ // F10: persist the sidecar verify result + the force-replay flag so the
2880
+ // audit trail records whether the replay was authenticated input.
2881
+ sidecar_verify: verify,
2882
+ force_replay: !!args["force-replay"],
2547
2883
  }, pretty);
2548
2884
  }
2549
2885
 
@@ -3989,9 +4325,13 @@ function cmdAsk(runner, args, runOpts, pretty) {
3989
4325
  * `run --all --ci` packaged as a verb so .github/workflows lines are short.
3990
4326
  *
3991
4327
  * Exit codes:
3992
- * 0 PASS — no detected findings, no rwep ≥ cap, no clock started (when
3993
- * --block-on-jurisdiction-clock is set).
3994
- * 2 FAIL any of the above tripped.
4328
+ * 0 PASS — no detected findings, no rwep ≥ cap, no clock fired.
4329
+ * 2 FAIL — detected classification OR rwep ≥ cap.
4330
+ * 3 NO_EVIDENCE every result inconclusive AND no --evidence supplied.
4331
+ * 4 BLOCKED — at least one playbook returned ok:false (preflight halt).
4332
+ * 5 CLOCK_STARTED — --block-on-jurisdiction-clock fired (F18); separated
4333
+ * from FAIL so operators distinguish "detected" from
4334
+ * "regulatory notification deadline running."
3995
4335
  */
3996
4336
  function cmdCi(runner, args, runOpts, pretty) {
3997
4337
  const scope = args.scope;
@@ -4063,6 +4403,11 @@ function cmdCi(runner, args, runOpts, pretty) {
4063
4403
  const results = [];
4064
4404
  let fail = false;
4065
4405
  let failReasons = [];
4406
+ // F18: track jurisdiction-clock signals separately from generic FAIL so the
4407
+ // exit code can distinguish "detected/escalated" (2) from "regulatory clock
4408
+ // running, operator must notify" (5). Pre-fix the two collapsed into exit 2.
4409
+ let clockStartedFail = false;
4410
+ let clockStartedReasons = [];
4066
4411
 
4067
4412
  for (const id of ids) {
4068
4413
  let pb;
@@ -4114,8 +4459,13 @@ function cmdCi(runner, args, runOpts, pretty) {
4114
4459
  failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
4115
4460
  }
4116
4461
  if (blockOnClock && clockStarted) {
4117
- fail = true;
4118
- failReasons.push(`${id}: jurisdiction clock started`);
4462
+ // F18: separate "clock started" from generic FAIL. Pre-fix this collapsed
4463
+ // into exit 2 (FAIL), so operators couldn't distinguish "playbook
4464
+ // detected" from "regulatory clock running." Tracked separately and
4465
+ // exit 5 (CLOCK_STARTED) is selected below, taking precedence over
4466
+ // FAIL but not BLOCKED.
4467
+ clockStartedFail = true;
4468
+ clockStartedReasons.push(`${id}: jurisdiction clock started`);
4119
4469
  }
4120
4470
  }
4121
4471
 
@@ -4133,13 +4483,22 @@ function cmdCi(runner, args, runOpts, pretty) {
4133
4483
  const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
4134
4484
  const totalForVerdict = results.length;
4135
4485
  const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
4486
+ // F18: precedence — BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
4487
+ // CLOCK_STARTED outranks FAIL because the operator explicitly opted into
4488
+ // the clock gate (--block-on-jurisdiction-clock); when that gate fires,
4489
+ // they want the regulatory-deadline signal even if a detected finding
4490
+ // also surfaces. (A detected finding is still in the body for the
4491
+ // operator to act on; the exit-code dimension just answers "what's the
4492
+ // top-line reason this gate failed.")
4136
4493
  const computedVerdict = blockedCount > 0
4137
4494
  ? "BLOCKED"
4138
- : fail
4139
- ? "FAIL"
4140
- : noEvidenceAllInconclusive
4141
- ? "NO_EVIDENCE"
4142
- : "PASS";
4495
+ : clockStartedFail
4496
+ ? "CLOCK_STARTED"
4497
+ : fail
4498
+ ? "FAIL"
4499
+ : noEvidenceAllInconclusive
4500
+ ? "NO_EVIDENCE"
4501
+ : "PASS";
4143
4502
 
4144
4503
  // v0.12.9 (P2 #8 from production smoke): roll up per-playbook framework_gap
4145
4504
  // mappings to the ci top-level. Phase 7 of the seven-phase contract surfaces
@@ -4180,8 +4539,15 @@ function cmdCi(runner, args, runOpts, pretty) {
4180
4539
  .filter(n => n && n.clock_started_at != null).length,
4181
4540
  framework_gap_rollup: frameworkGapRollup,
4182
4541
  framework_gap_count: frameworkGapRollup.length,
4542
+ // F13: dedupe jurisdiction-clock notifications across playbooks; see
4543
+ // buildJurisdictionClockRollup. Multi-playbook ci runs were producing
4544
+ // one notification entry per contributing playbook (often 8+) when a
4545
+ // single notification per (jurisdiction, regulation, obligation,
4546
+ // window) was the right shape.
4547
+ jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
4183
4548
  verdict: computedVerdict,
4184
4549
  fail_reasons: failReasons,
4550
+ clock_started_reasons: clockStartedReasons,
4185
4551
  };
4186
4552
 
4187
4553
  // v0.11.4 (#72): ci --format <fmt> previously emitted the full bundle
@@ -4241,6 +4607,15 @@ function cmdCi(runner, args, runOpts, pretty) {
4241
4607
  process.exitCode = 4;
4242
4608
  return;
4243
4609
  }
4610
+ // F18: precedence BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
4611
+ // --block-on-jurisdiction-clock; when a clock fires, that's the gate
4612
+ // result they want to see at the exit-code layer. Per-playbook detected
4613
+ // findings remain in the body for them to investigate.
4614
+ if (clockStartedFail) {
4615
+ process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit 5.\n`);
4616
+ process.exitCode = 5;
4617
+ return;
4618
+ }
4244
4619
  if (fail) {
4245
4620
  process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
4246
4621
  // v0.11.11: exitCode + return so emit()'s stdout flushes.
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-14T16:47:17.975Z",
3
+ "generated_at": "2026-05-14T19:30:40.635Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 50,
6
6
  "source_hashes": {
7
- "manifest.json": "e0db0f6421e782c796a972277b1ac6774222fa245ae109b87a29f5122a6eb972",
7
+ "manifest.json": "c0edebf10be0a638970d4e9e4c95459815f0226fc69276d1c965a71fa39b324f",
8
8
  "data/atlas-ttps.json": "20339e0ae3cd89c06f1385be31c50f408f827edc2e8ab8aef026ade3bcf0a917",
9
9
  "data/attack-techniques.json": "6db08a8e8a4d03d9309b1d185112de7f3c9595d2cd3d24566b7ce0b3b8aa5d1a",
10
- "data/cve-catalog.json": "54e04fc72a1b85dd75d46dbbf646bed5f489f867df752800c62498fc0d4ee428",
10
+ "data/cve-catalog.json": "6e198d414a3a86dcae93ef36a2b1978734d0b1224fa66ba5184819ea0e3fb49f",
11
11
  "data/cwe-catalog.json": "19893d2a7139d86ff3fcf296b0e6cda10e357727a1d1ffb56af282104e99157a",
12
12
  "data/d3fend-catalog.json": "d219520c8d3eb61a270b25ea60f64721035e98a8d5d51d1a4e1f1140d9a586f9",
13
13
  "data/dlp-controls.json": "8ea8d907aea0a2cfd772b048a62122a322ba3284a5c36a272ad5e9d392564cb5",
@@ -1226,4 +1226,4 @@
1226
1226
  },
1227
1227
  "last_updated": "2026-05-13"
1228
1228
  }
1229
- }
1229
+ }