@blamejs/exceptd-skills 0.12.21 → 0.12.23

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
@@ -85,10 +85,21 @@ function assertExpectedFingerprint(pubKeyPem) {
85
85
  } catch (e) {
86
86
  return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
87
87
  }
88
- const raw = fs.readFileSync(pinPath, "utf8");
89
- const firstLine = raw.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || "";
88
+ // Route through the shared lib/verify loader so a BOM-prefixed pin file
89
+ // (Notepad with files.encoding=utf8bom) is tolerated identically across
90
+ // every verify site. The helper strips leading U+FEFF + ignores comment
91
+ // lines.
92
+ const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
93
+ const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
90
94
  if (firstLine === liveFp) return null;
91
- if (process.env.KEYS_ROTATED === "1") return null;
95
+ if (process.env.KEYS_ROTATED === "1") {
96
+ process.emitWarning(
97
+ `EXPECTED_FINGERPRINT mismatch accepted via KEYS_ROTATED=1: live=${liveFp} pin=${firstLine}. ` +
98
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
99
+ { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
100
+ );
101
+ return null;
102
+ }
92
103
  return (
93
104
  `EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
94
105
  `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
@@ -264,9 +275,11 @@ v0.12.0 canonical surface
264
275
  --registry-check (v0.11.14) opt-in: query npm registry
265
276
  for latest published version + days behind
266
277
 
267
- ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
268
- 3 ran-but-no-evidence, 4 blocked (ok:false),
269
- 5 jurisdiction clock started, 1 framework error.
278
+ ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
279
+ 2 detected/escalate, 3 ran-but-no-evidence,
280
+ 4 blocked (ok:false), 5 jurisdiction clock started,
281
+ 6 TAMPERED (sidecar verification failed),
282
+ 8 LOCK_CONTENTION (concurrent playbook lock held).
270
283
  --all | --scope <type> | (auto-detect)
271
284
  --max-rwep <n> cap below playbook default
272
285
  --block-on-jurisdiction-clock
@@ -491,11 +504,9 @@ function main() {
491
504
  if (typeof resolver !== "function") {
492
505
  // Emit a structured JSON error matching the seven-phase verbs so operators
493
506
  // piping through `jq` get one consistent shape across the CLI surface.
494
- // R-F8: pre-fix, the structured-JSON stderr write was followed by
495
- // process.exit(2) the v0.11.10 truncation class applied to stderr
496
- // just as it does to stdout. Route through emitError() (which uses
497
- // exitCode + return per v0.12.14) so the JSON drains, then promote
498
- // the exit code to 2 (unknown-command remains a distinct exit class).
507
+ // emitError() sets exitCode + returns rather than calling process.exit()
508
+ // so the stderr JSON drains before teardown; promote the exit code to 2
509
+ // afterwards (unknown-command remains a distinct exit class).
499
510
  emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
500
511
  process.exitCode = 2;
501
512
  return;
@@ -503,7 +514,7 @@ function main() {
503
514
 
504
515
  const script = resolver();
505
516
  if (!fs.existsSync(script)) {
506
- // R-F8: same class — emitError + exitCode rather than stderr + exit().
517
+ // emitError + exitCode rather than stderr + exit() so the JSON drains.
507
518
  emitError(
508
519
  `command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
509
520
  { verb: cmd }
@@ -517,7 +528,7 @@ function main() {
517
528
  const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
518
529
  const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
519
530
  if (res.error) {
520
- // R-F8: same class emitError + exitCode.
531
+ // emitError + exitCode rather than stderr + exit() so the JSON drains.
521
532
  emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
522
533
  process.exitCode = 2;
523
534
  return;
@@ -620,7 +631,7 @@ function emitError(msg, extra, pretty) {
620
631
  }
621
632
 
622
633
  /**
623
- * EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
634
+ * Shared BOM-tolerant JSON file reader. Windows tools commonly emit
624
635
  * UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
625
636
  * `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
626
637
  * becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
@@ -644,7 +655,21 @@ function readJsonFile(filePath) {
644
655
  text = buf.slice(2).toString("utf16le");
645
656
  } else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
646
657
  // UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
647
- const swapped = Buffer.allocUnsafe(buf.length - 2);
658
+ //
659
+ // refuse odd-length payloads up front rather than carry
660
+ // the trailing byte through a partial swap. A UTF-16BE payload by
661
+ // definition has an even byte count after the BOM; odd-length input is
662
+ // either truncated or not UTF-16BE at all.
663
+ //
664
+ // use Buffer.alloc (zero-initialised) instead of
665
+ // Buffer.allocUnsafe so an unexpected loop bound never lets uninitialised
666
+ // heap bytes leak into the decoded string and downstream JSON.parse
667
+ // error message.
668
+ const payloadLength = buf.length - 2;
669
+ if (payloadLength % 2 !== 0) {
670
+ throw new Error(`failed to read ${filePath}: UTF-16BE payload must have an even byte count after BOM; got ${payloadLength} bytes — file may be truncated.`);
671
+ }
672
+ const swapped = Buffer.alloc(payloadLength);
648
673
  for (let i = 2; i < buf.length - 1; i += 2) {
649
674
  swapped[i - 2] = buf[i + 1];
650
675
  swapped[i - 1] = buf[i];
@@ -681,7 +706,7 @@ function readEvidence(evidenceFlag) {
681
706
  if (stat.size > MAX_EVIDENCE_BYTES) {
682
707
  throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
683
708
  }
684
- // EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
709
+ // Route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
685
710
  // Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
686
711
  // decode in readFileSync chokes on the leading 0xFEFF.
687
712
  return readJsonFile(evidenceFlag);
@@ -692,7 +717,7 @@ function loadRunner() {
692
717
  }
693
718
 
694
719
  /**
695
- * EE P1-7: detect whether stdin actually has data without blocking.
720
+ * Detect whether stdin actually has data without blocking.
696
721
  *
697
722
  * `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
698
723
  * `false`, `undefined`, OR `null`. Test harnesses with custom stdin
@@ -704,15 +729,17 @@ function loadRunner() {
704
729
  * Strategy:
705
730
  *
706
731
  * 1. If isTTY is truthy → operator is at a terminal, never read stdin.
707
- * 2. Probe `fs.fstatSync(0)`:
708
- * - On POSIX pipes / regular files, `stat.size` is reliable.
709
- * - On Windows, fstat on a pipe returns size === 0 even when data
710
- * is queued so size === 0 alone cannot decide.
711
- * 3. When size > 0 real data is queued; safe to read.
712
- * 4. When size === 0 AND isTTY is falsy:
713
- * - On POSIX, treat as empty (wrapped duplexer / closed stdin).
714
- * - On Windows, fall back to the legacy truthy check so we don't
715
- * regress the MSYS-bash auto-detect (R-F3 in v0.12.16).
732
+ * 2. POSIX: trust isFIFO / isSocket / isCharacterDevice. Regular file
733
+ * requires size > 0 (empty file redirection should not be treated
734
+ * as piped input).
735
+ * 3. Windows: `isTTY === false` strict (filters out wrapped test
736
+ * duplexers which leave isTTY === undefined). DO NOT gate on size
737
+ * because Windows pipes report as regular files with size 0 even
738
+ * when bytes are queued gating would silently skip every
739
+ * `echo {...} | exceptd run` invocation.
740
+ * 4. If a wrapped test harness on Windows does want stdin auto-read
741
+ * to skip, the harness must set `process.stdin.isTTY = undefined`
742
+ * explicitly (Mocha/Jest do this by default).
716
743
  *
717
744
  * Returns `true` if the caller may safely fs.readFileSync(0) without
718
745
  * risking an indefinite block on a wrapped empty stream.
@@ -721,7 +748,16 @@ function hasReadableStdin() {
721
748
  if (process.stdin.isTTY) return false;
722
749
  let st;
723
750
  try { st = fs.fstatSync(0); }
724
- catch { return !process.stdin.isTTY; /* fstat failed — fall back */ }
751
+ catch {
752
+ // fstat failed — on Windows require `isTTY === false` STRICTLY (not
753
+ // falsy). A non-strict check returns true when isTTY is undefined (e.g.
754
+ // Mocha/Jest test harnesses with a wrapped duplexer on Windows), which
755
+ // causes fs.readFileSync(0) to block indefinitely waiting on an EOF
756
+ // that never arrives. MSYS-bash piping on win32 sets isTTY === false,
757
+ // so the strict check still admits genuine piped input.
758
+ if (process.platform === "win32") return process.stdin.isTTY === false;
759
+ return false;
760
+ }
725
761
  // POSIX pipes / FIFOs / sockets / character devices report size 0
726
762
  // even when bytes are queued (or about to be). Trust them — a real
727
763
  // `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
@@ -737,19 +773,22 @@ function hasReadableStdin() {
737
773
  if (typeof st.size === "number" && st.size > 0) return true;
738
774
  // Windows fallback: pipes don't surface as FIFOs via fstat on win32
739
775
  // (they appear as regular files with size 0 even when bytes queued).
740
- // Preserve the legacy truthy check so MSYS-bash piping keeps working.
741
- if (process.platform === "win32") return !process.stdin.isTTY;
776
+ // Trust isTTY === false strictly that filters out wrapped test
777
+ // duplexers (which leave isTTY === undefined) while keeping cmd.exe /
778
+ // PowerShell / MSYS pipes working (isTTY === false when piped). Do NOT
779
+ // gate on size > 0 here: a Windows pipe with bytes queued reports as
780
+ // a regular file with size 0, and gating would silently skip every
781
+ // `echo {...} | exceptd run|ingest|ai-run` invocation.
782
+ if (process.platform === "win32" && process.stdin.isTTY === false) return true;
742
783
  return false;
743
784
  }
744
785
 
745
786
  /**
746
- * R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
747
- * `attest list --since` and `reattest --since` accepted anything Date.parse
748
- * could chew on including bare integers like "99", which JavaScript
749
- * happily resolves to 1999-12-01T00:00:00Z (Y2K-era millisecond / two-digit
750
- * year heuristic). Operators got a "valid timestamp" check that silently
751
- * filtered the wrong years. Now: require an explicit calendar-date shape
752
- * (YYYY-MM-DD with optional time component) BEFORE handing to Date.parse.
787
+ * ISO-8601 shape regex applied BEFORE Date.parse for --since flags. Without
788
+ * the regex check, bare integers like "99" coerce through Date.parse to
789
+ * 1999-12-01T00:00:00Z (two-digit-year heuristic), silently filtering the
790
+ * wrong years. Requires an explicit calendar-date shape (YYYY-MM-DD with
791
+ * optional time component) before handing to Date.parse.
753
792
  *
754
793
  * Returns null on success; returns the human-facing error message string
755
794
  * on failure so the caller can wrap it with its own verb prefix.
@@ -763,7 +802,7 @@ function validateIsoSince(raw) {
763
802
  }
764
803
 
765
804
  /**
766
- * F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
805
+ * Detect whether a parsed JSON document is plausibly CycloneDX VEX or
767
806
  * OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
768
807
  * anything else, which means an operator who passes SARIF / SBOM / CSAF /
769
808
  * advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
@@ -785,12 +824,12 @@ function detectVexShape(doc) {
785
824
  const isBom = doc.bomFormat === "CycloneDX";
786
825
  const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
787
826
  const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
788
- // R-F4: empty vulnerabilities arrays cannot vouch for CycloneDX shape
789
- // on their own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}`
790
- // previously passed because `length === 0` always satisfied
827
+ // Empty vulnerabilities arrays cannot vouch for CycloneDX shape on their
828
+ // own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}` would
829
+ // otherwise pass because `length === 0` trivially satisfies
791
830
  // `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
792
- // specVersion) when the array is empty; non-empty arrays still pass
793
- // when any entry has vex-shaped fields (id / bom-ref / analysis).
831
+ // specVersion) when the array is empty; non-empty arrays still pass when
832
+ // any entry has vex-shaped fields (id / bom-ref / analysis).
794
833
  if (doc.vulnerabilities.length === 0) {
795
834
  if (hasCyclonedxMarker) {
796
835
  return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
@@ -815,8 +854,8 @@ function detectVexShape(doc) {
815
854
  if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
816
855
  return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
817
856
  }
818
- // EE P1-1: a CycloneDX SBOM with no `vulnerabilities` key is a legitimate
819
- // "0-CVE VEX filter" submission — the operator is asserting nothing here is
857
+ // A CycloneDX SBOM with no `vulnerabilities` key is a legitimate "0-CVE
858
+ // VEX filter" submission — the operator is asserting nothing here is
820
859
  // exploitable. Accept it as cyclonedx-vex with an empty filter set (the
821
860
  // runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
822
861
  // for documents that carry a CycloneDX-flavored specVersion ("1.x") without
@@ -893,6 +932,17 @@ function dispatchPlaybook(cmd, argv) {
893
932
  pretty
894
933
  );
895
934
  }
935
+ // The character-class regex accepts any all-dots string (`.`, `..`,
936
+ // `...`); each resolves into or above the attestation root. Refuse
937
+ // them explicitly so the attestation is never written outside the
938
+ // intended directory.
939
+ if (/^\.+$/.test(sid)) {
940
+ return emitError(
941
+ "run: --session-id cannot consist entirely of dots (rejected: '.', '..', etc.).",
942
+ { provided: sid },
943
+ pretty
944
+ );
945
+ }
896
946
  runOpts.session_id = sid;
897
947
  }
898
948
  if (args["attestation-root"]) {
@@ -938,12 +988,12 @@ function dispatchPlaybook(cmd, argv) {
938
988
  // service identity. --operator <name> persists into the attestation file
939
989
  // for audit-trail accountability.
940
990
  //
941
- // F9: validate the input. Pre-fix the value flowed into runOpts unchanged,
942
- // so an operator could inject newlines / control chars / arbitrary length
943
- // into attestation export output (multi-line "operator:" key/value pairs
944
- // are a forgery surface — a forged second line could look like a separate
945
- // attestation field to a naive parser). Now: strip ASCII control chars
946
- // (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
991
+ // Validate the input. Without this, a value flows into runOpts unchanged
992
+ // and an operator could inject newlines / control chars / arbitrary
993
+ // length into attestation export output (multi-line "operator:" key/value
994
+ // pairs are a forgery surface — a forged second line could look like a
995
+ // separate attestation field to a naive parser). Strip ASCII control
996
+ // chars (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
947
997
  if (args.operator !== undefined) {
948
998
  if (typeof args.operator !== "string") {
949
999
  return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
@@ -970,8 +1020,8 @@ function dispatchPlaybook(cmd, argv) {
970
1020
  pretty
971
1021
  );
972
1022
  }
973
- // EE P1-3: the ASCII-only control-char regex above misses Unicode
974
- // categories Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
1023
+ // The ASCII-only control-char regex above misses Unicode categories
1024
+ // Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
975
1025
  // zero-width joiners (U+200B-D), invisible format chars, private-use
976
1026
  // codepoints, unassigned codepoints. An operator string like
977
1027
  // "alice‮evilbob" renders as "alicebobevila" in any UI that respects
@@ -1020,7 +1070,20 @@ function dispatchPlaybook(cmd, argv) {
1020
1070
  runOpts.operator = normalized;
1021
1071
  }
1022
1072
 
1023
- // audit CC P1-3: --publisher-namespace <url> threads into the CSAF
1073
+ // --csaf-status and --publisher-namespace shape the CSAF bundle emitted by
1074
+ // phases 5-7. Verbs that don't drive those phases (brief, plan, govern,
1075
+ // direct, look, attest, list-attestations, discover, doctor, lint, ask,
1076
+ // verify-attestation, reattest) never assemble a bundle, so silently
1077
+ // consuming these flags is a UX trap. Refuse on those verbs so the
1078
+ // operator knows the flag was discarded — same pattern as --ack. Error
1079
+ // message templates and emitError prefixes use the in-scope `cmd` verb so
1080
+ // a brief invocation says "brief:" rather than misattributing the flag
1081
+ // to run.
1082
+ const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
1083
+ "run", "ci", "run-all", "ai-run", "ingest",
1084
+ ]);
1085
+
1086
+ // --publisher-namespace <url> threads into the CSAF
1024
1087
  // bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
1025
1088
  // namespace to be the publisher's trust anchor — i.e. the OPERATOR
1026
1089
  // running the scan, not the tooling vendor. Pre-fix this was hard-coded
@@ -1028,28 +1091,35 @@ function dispatchPlaybook(cmd, argv) {
1028
1091
  // accuracy. Validation mirrors --operator (string, ≤256 chars, no
1029
1092
  // ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
1030
1093
  if (args["publisher-namespace"] !== undefined) {
1094
+ if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
1095
+ return emitError(
1096
+ `${cmd}: --publisher-namespace is irrelevant on this verb (no CSAF bundle is assembled). --publisher-namespace only applies to verbs that drive phases 5-7: ${[...BUNDLE_FLAG_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --publisher-namespace, or pass it on \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --publisher-namespace <url>\` once you're past the briefing step.`,
1097
+ { verb: cmd, flag: "publisher-namespace", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
1098
+ pretty
1099
+ );
1100
+ }
1031
1101
  const ns = args["publisher-namespace"];
1032
1102
  if (typeof ns !== "string") {
1033
- return emitError("run: --publisher-namespace must be a string.", { provided: typeof ns }, pretty);
1103
+ return emitError(`${cmd}: --publisher-namespace must be a string.`, { provided: typeof ns }, pretty);
1034
1104
  }
1035
1105
  // eslint-disable-next-line no-control-regex
1036
1106
  if (/[\x00-\x1F\x7F]/.test(ns)) {
1037
1107
  return emitError(
1038
- "run: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.",
1108
+ `${cmd}: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.`,
1039
1109
  { provided_length: ns.length },
1040
1110
  pretty
1041
1111
  );
1042
1112
  }
1043
1113
  if (ns.length === 0 || ns.length > 256) {
1044
1114
  return emitError(
1045
- `run: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
1115
+ `${cmd}: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
1046
1116
  { provided_length: ns.length },
1047
1117
  pretty
1048
1118
  );
1049
1119
  }
1050
1120
  if (!/^https?:\/\//i.test(ns)) {
1051
1121
  return emitError(
1052
- "run: --publisher-namespace must be a URL starting with http:// or https:// (e.g. https://your-org.example). CSAF §3.1.7.4 requires the namespace to be the publisher's trust anchor.",
1122
+ `${cmd}: --publisher-namespace must be a URL starting with http:// or https:// (e.g. https://your-org.example). CSAF §3.1.7.4 requires the namespace to be the publisher's trust anchor.`,
1053
1123
  { provided: ns.slice(0, 80) },
1054
1124
  pretty
1055
1125
  );
@@ -1057,18 +1127,25 @@ function dispatchPlaybook(cmd, argv) {
1057
1127
  runOpts.publisherNamespace = ns;
1058
1128
  }
1059
1129
 
1060
- // audit CC P1-1: --csaf-status promotes the CSAF tracking.status from the
1130
+ // --csaf-status promotes the CSAF tracking.status from the
1061
1131
  // runtime default (`interim`) to `final` for operators who have reviewed
1062
1132
  // the advisory and accept the immutable-advisory contract of CSAF
1063
1133
  // §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
1064
1134
  // rejected at input so an operator typo (`finel`) doesn't silently fall
1065
1135
  // back to interim and produce surprise.
1066
1136
  if (args["csaf-status"] !== undefined) {
1137
+ if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
1138
+ return emitError(
1139
+ `${cmd}: --csaf-status is irrelevant on this verb (no CSAF bundle is assembled). --csaf-status only applies to verbs that drive phases 5-7: ${[...BUNDLE_FLAG_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --csaf-status, or pass it on \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --csaf-status <status>\` once you're past the briefing step.`,
1140
+ { verb: cmd, flag: "csaf-status", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
1141
+ pretty
1142
+ );
1143
+ }
1067
1144
  const cs = args["csaf-status"];
1068
1145
  const allowed = ["draft", "interim", "final"];
1069
1146
  if (typeof cs !== "string" || !allowed.includes(cs)) {
1070
1147
  return emitError(
1071
- `run: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
1148
+ `${cmd}: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
1072
1149
  { provided: cs },
1073
1150
  pretty
1074
1151
  );
@@ -1081,13 +1158,13 @@ function dispatchPlaybook(cmd, argv) {
1081
1158
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
1082
1159
  // and wait for ack — this is how the ack gets recorded.
1083
1160
  //
1084
- // EE P1-6: --ack only makes sense on verbs that drive phases 5-7 (run /
1085
- // ingest / ai-run / ci / run-all / reattest). Info-only verbs (brief,
1086
- // plan, govern, direct, look, attest, list-attestations, discover,
1087
- // doctor, lint, ask, verify-attestation) never consume an attestation
1088
- // clock — accepting --ack silently here was a UX trap where operators
1089
- // believed they had recorded consent. Refuse on those verbs so the
1090
- // operator knows the flag is irrelevant.
1161
+ // --ack only makes sense on verbs that drive phases 5-7 (run / ingest /
1162
+ // ai-run / ci / run-all / reattest). Info-only verbs (brief, plan,
1163
+ // govern, direct, look, attest, list-attestations, discover, doctor,
1164
+ // lint, ask, verify-attestation) never consume an attestation clock —
1165
+ // accepting --ack silently is a UX trap where operators believe they have
1166
+ // recorded consent. Refuse on those verbs so the operator knows the flag
1167
+ // is irrelevant.
1091
1168
  const ACK_RELEVANT_VERBS = new Set([
1092
1169
  "run", "ingest", "ai-run", "ci", "run-all", "reattest",
1093
1170
  ]);
@@ -1305,6 +1382,15 @@ Flags:
1305
1382
  --ack Mark explicit operator consent to the jurisdiction
1306
1383
  obligations surfaced by govern. Persisted under
1307
1384
  attestation.operator_consent.
1385
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1386
+ bundle. One of: draft | interim (default) | final.
1387
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1388
+ set this only after operator review of the advisory.
1389
+ --publisher-namespace <url>
1390
+ CSAF document.publisher.namespace (§3.1.7.4). The
1391
+ publisher trust anchor — i.e. the operator's
1392
+ organisation, NOT the tooling vendor. Must be an
1393
+ http://… or https://… URL, ≤256 chars.
1308
1394
  --diff-from-latest Compare evidence_hash against the most recent prior
1309
1395
  attestation for the same playbook in
1310
1396
  .exceptd/attestations/. Emits status: unchanged | drifted.
@@ -1333,13 +1419,36 @@ Flags:
1333
1419
  --pretty Indented JSON output.
1334
1420
 
1335
1421
  Attestation is persisted to .exceptd/attestations/<session_id>/ on every
1336
- successful run (single: attestation.json; multi: <playbook_id>.json).`,
1422
+ successful run (single: attestation.json; multi: <playbook_id>.json).
1423
+
1424
+ Exit codes (per-verb, post-run):
1425
+ 0 PASS Run completed; classification clean, RWEP under cap.
1426
+ 1 Framework error Runner threw, unreadable evidence, etc.
1427
+ 2 FAIL (detected) classification=detected OR rwep ≥ escalate cap.
1428
+ 3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
1429
+ 4 Blocked Result returned ok:false (preflight halt).
1430
+ 5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
1431
+ 8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
1432
+ attestation lock after the bounded retry budget
1433
+ (~1-2s). Distinct from 1 so callers can retry the
1434
+ operation rather than treat it as a hard failure.
1435
+ Surfaces as body.lock_contention=true,
1436
+ body.exit_code=8.
1437
+ 6-7 — reserved (6=TAMPERED on attest verifier; 7 unused)`,
1337
1438
  ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
1338
1439
 
1339
1440
  Flags:
1340
1441
  --domain <id> Playbook ID (overrides submission.playbook_id).
1341
1442
  --directive <id> Directive ID (overrides submission.directive_id).
1342
1443
  --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
1444
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1445
+ bundle. One of: draft | interim (default) | final.
1446
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1447
+ set this only after operator review of the advisory.
1448
+ --publisher-namespace <url>
1449
+ CSAF document.publisher.namespace (§3.1.7.4). The
1450
+ operator's organisation URL, NOT the tooling vendor.
1451
+ Must be an http://… or https://… URL, ≤256 chars.
1343
1452
  --pretty Indented JSON output.`,
1344
1453
  reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
1345
1454
 
@@ -1350,7 +1459,12 @@ Args / flags:
1350
1459
  --since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
1351
1460
  --pretty Indented JSON output.
1352
1461
 
1353
- Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
1462
+ Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.
1463
+
1464
+ Exit codes:
1465
+ 0 verification succeeded
1466
+ 1 generic failure
1467
+ 6 TAMPERED (sidecar or signature mismatch on the prior attestation)`,
1354
1468
  "list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
1355
1469
 
1356
1470
  Args / flags:
@@ -1380,7 +1494,12 @@ Subverbs:
1380
1494
  for an explicit pair. Reports unchanged | drifted |
1381
1495
  resolved per evidence_hash + classification deltas.
1382
1496
 
1383
- All subverbs honor --pretty for indented JSON output.`,
1497
+ All subverbs honor --pretty for indented JSON output.
1498
+
1499
+ Exit codes (attest verify):
1500
+ 0 verification succeeded
1501
+ 1 generic failure
1502
+ 6 TAMPERED (sidecar or signature mismatch)`,
1384
1503
  discover: `discover — context-aware playbook recommender (v0.11.0).
1385
1504
 
1386
1505
  Replaces: scan + dispatch + recommend.
@@ -1435,6 +1554,14 @@ Flags:
1435
1554
  --directive <id> Specific directive (default: first one).
1436
1555
  --no-stream Single-shot mode: emit all phases as one JSON doc
1437
1556
  without reading stdin (uses runner.run directly).
1557
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1558
+ bundle. One of: draft | interim (default) | final.
1559
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1560
+ set this only after operator review of the advisory.
1561
+ --publisher-namespace <url>
1562
+ CSAF document.publisher.namespace (§3.1.7.4). The
1563
+ operator's organisation URL, NOT the tooling vendor.
1564
+ Must be an http://… or https://… URL, ≤256 chars.
1438
1565
  --pretty Indented JSON output (single-shot only).
1439
1566
 
1440
1567
  Stdin event grammar (one JSON object per line):
@@ -1493,6 +1620,11 @@ Flags:
1493
1620
  summary (5-field digest), markdown (human digest).
1494
1621
  Bundles (csaf-2.0/sarif/openvex) live on per-run
1495
1622
  attestations, not the aggregate ci verdict.
1623
+ --csaf-status <s> CSAF tracking.status threaded into per-run bundles.
1624
+ One of: draft | interim (default) | final.
1625
+ --publisher-namespace <url>
1626
+ CSAF document.publisher.namespace (§3.1.7.4). The
1627
+ operator's organisation URL, NOT the tooling vendor.
1496
1628
  --json Force single-line JSON (overrides any TTY heuristics).
1497
1629
  --pretty Indented JSON output (implies --json).
1498
1630
 
@@ -1510,6 +1642,13 @@ Exit codes:
1510
1642
  close.notification_actions entry started a
1511
1643
  regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
1512
1644
  etc.) and the operator has not acked.
1645
+ 6 TAMPERED Attestation sidecar verification failed (Ed25519
1646
+ signature mismatch on a prior session referenced
1647
+ by the run, or the replay record's sidecar did
1648
+ not verify against keys/public.pem).
1649
+ 8 LOCK_CONTENTION Concurrent run holds the per-playbook attestation
1650
+ lock; the bounded retry budget (~1-2s) elapsed
1651
+ without acquiring it. Retry the operation.
1513
1652
 
1514
1653
  Output: verb, session_id, playbooks_run, summary{total, detected,
1515
1654
  max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
@@ -1562,7 +1701,15 @@ Flags: --pretty.`,
1562
1701
  Identical exit-code and output contract as \`run --all\`. Maintained for
1563
1702
  operators who script the verb form rather than the flag.
1564
1703
 
1565
- See \`exceptd run --help\` for the full flag list.`,
1704
+ Flags (selected — see \`exceptd run --help\` for the full list):
1705
+ --csaf-status <s> CSAF tracking.status for per-run close.evidence_package
1706
+ bundles. One of: draft | interim (default) | final.
1707
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1708
+ set this only after operator review of the advisory.
1709
+ --publisher-namespace <url>
1710
+ CSAF document.publisher.namespace (§3.1.7.4). The
1711
+ operator's organisation URL, NOT the tooling vendor.
1712
+ Must be an http://… or https://… URL, ≤256 chars.`,
1566
1713
  };
1567
1714
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
1568
1715
  }
@@ -1964,13 +2111,12 @@ function cmdRun(runner, args, runOpts, pretty) {
1964
2111
  // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
1965
2112
  // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
1966
2113
  // from the cwd.
1967
- // R-F9: gate on `args.scope !== undefined` rather than `args.scope`
1968
- // truthy. Pre-fix, `--scope ""` parsed to `args.scope === ""`, which
1969
- // is falsy the dispatcher fell through to the auto-detect path and
1970
- // silently ran whatever scopes happened to match the cwd, masking the
1971
- // operator's explicit (if malformed) intent. Now: an empty string
1972
- // reaches validateScopeOrThrow which rejects with the accepted-set
1973
- // message, matching the rest of the v0.12.15 scope-validation contract.
2114
+ // Gate on `args.scope !== undefined` rather than truthy `args.scope`.
2115
+ // `--scope ""` parses to `args.scope === ""`, which is falsy; a truthy
2116
+ // gate would silently fall through to auto-detect and run whatever
2117
+ // scopes happened to match the cwd, masking the operator's explicit
2118
+ // (if malformed) intent. An empty string reaches validateScopeOrThrow
2119
+ // which rejects with the accepted-set message.
1974
2120
  if (!positional && (args.all || args.scope !== undefined)) {
1975
2121
  let ids;
1976
2122
  if (args.all) {
@@ -2048,20 +2194,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2048
2194
  // v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
2049
2195
  // pipe, assume `--evidence -`. Operators forgetting the flag previously
2050
2196
  // got a confusing precondition halt; now the common case "just works."
2051
- // R-F3: use truthy `!process.stdin.isTTY` instead of `=== false`. On
2052
- // Windows MSYS bash, process.stdin.isTTY is `undefined` for a piped
2053
- // stream the strict `=== false` check failed and auto-detect never
2054
- // fired, making `echo '{...}' | exceptd run <pb>` silently behave like
2055
- // no-evidence on Windows. cmdAiRun's path (below) already uses the
2056
- // truthy form, so this brings cmdRun + cmdIngest to parity.
2057
- //
2058
- // EE P1-7: use the fstat-probing hasReadableStdin() helper instead of
2059
- // the raw `!process.stdin.isTTY` truthy check. Test harnesses with
2060
- // wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
2061
- // isTTY === undefined but have no data — the raw check fell into
2062
- // readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
2063
- // hasReadableStdin() does an fstat() probe first, then falls back to
2064
- // the truthy check only on Windows (where fstat on a pipe is unreliable).
2197
+ // Use the fstat-probing hasReadableStdin() helper. A raw `!isTTY` check
2198
+ // fires when isTTY is undefined (test harnesses with wrapped duplexers —
2199
+ // Mocha/Jest, Docker stdin-passthrough leave isTTY === undefined but
2200
+ // never write any bytes), which causes readFileSync(0) to block waiting
2201
+ // on an EOF that never arrives. hasReadableStdin() does an fstat() probe
2202
+ // first, then falls back to a strict isTTY===false check only on Windows
2203
+ // (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
2204
+ // isTTY === false for genuine piped input, so that path still works.
2065
2205
  if (!args.evidence && hasReadableStdin()) {
2066
2206
  args.evidence = "-";
2067
2207
  }
@@ -2097,11 +2237,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2097
2237
  // CVE ID set through to analyze() so matched_cves drops them.
2098
2238
  if (args.vex) {
2099
2239
  let vexDoc;
2100
- // R-F5: cap --vex file size the same way readEvidence() caps --evidence
2101
- // (32 MiB — binary mebibytes, i.e. 32 * 1024 * 1024 = 33,554,432 bytes).
2102
- // Pre-fix, --vex did a raw readFileSync with no size check an operator
2103
- // passing a multi-GB file (binary log, JSON bomb, or accident) blocked
2104
- // the event loop for minutes / OOM'd the process. 32 MiB is well beyond
2240
+ // Cap --vex file size at 32 MiB (binary mebibytes, i.e. 32 * 1024 * 1024
2241
+ // = 33,554,432 bytes), matching readEvidence()'s --evidence cap. Without
2242
+ // the cap, a multi-GB file (binary log, JSON bomb, or accident) blocks
2243
+ // the event loop for minutes / OOM's the process. 32 MiB is well beyond
2105
2244
  // any legitimate VEX submission.
2106
2245
  const MAX_VEX_BYTES = 32 * 1024 * 1024;
2107
2246
  let vstat;
@@ -2110,7 +2249,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2110
2249
  return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
2111
2250
  }
2112
2251
  if (vstat.size > MAX_VEX_BYTES) {
2113
- // EE P1-4: error message names the binary mebi convention explicitly so
2252
+ // Error message names the binary mebi convention explicitly so
2114
2253
  // operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
2115
2254
  return emitError(
2116
2255
  `run: --vex file too large: ${vstat.size} bytes exceeds 32 MiB limit (${MAX_VEX_BYTES.toLocaleString("en-US")} bytes). Reduce the document or split into multiple passes.`,
@@ -2119,14 +2258,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2119
2258
  );
2120
2259
  }
2121
2260
  try {
2122
- // EE P1-2: BOM-tolerant read. Windows-tool-emitted CycloneDX commonly
2123
- // carries UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
2261
+ // BOM-tolerant read. Windows-tool-emitted CycloneDX commonly carries
2262
+ // UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
2124
2263
  // readFileSync chokes on the leading 0xFEFF.
2125
2264
  vexDoc = readJsonFile(args.vex);
2126
2265
  } catch (e) {
2127
2266
  return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
2128
2267
  }
2129
- // F5: validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
2268
+ // Validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
2130
2269
  // The runner tolerantly returns Set(0) for anything that's not CycloneDX
2131
2270
  // or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
2132
2271
  // advisory by mistake got ZERO filter applied and ZERO feedback. Now:
@@ -2144,15 +2283,13 @@ function cmdRun(runner, args, runOpts, pretty) {
2144
2283
  const vexSet = runner.vexFilterFromDoc(vexDoc);
2145
2284
  submission.signals = submission.signals || {};
2146
2285
  submission.signals.vex_filter = [...vexSet];
2147
- // BB P1-3: vexFilterFromDoc attaches a `.fixed` Set as an own property
2148
- // on the returned filter Set (CycloneDX `analysis.state: 'resolved'`
2149
- // + OpenVEX `status: 'fixed'` go into this set). Without forwarding it
2150
- // through to signals.vex_fixed, analyze() never receives the fixed-
2151
- // disposition CVE ids — `vexFixed` stays null, `vex_status: 'fixed'`
2152
- // never gets annotated onto matched_cves entries, and CSAF
2153
- // product_status.fixed + OpenVEX status:'fixed' are unreachable from
2154
- // the CLI. The bundle-correctness tests only exercised the analyze()
2155
- // direct-call path with vex_fixed pre-injected, hiding this regression.
2286
+ // vexFilterFromDoc attaches a `.fixed` Set as an own property on the
2287
+ // returned filter Set (CycloneDX `analysis.state: 'resolved'` + OpenVEX
2288
+ // `status: 'fixed'` populate it). Forward it through to
2289
+ // signals.vex_fixed so analyze() receives the fixed-disposition CVE
2290
+ // ids, `vex_status: 'fixed'` annotates matched_cves entries, and CSAF
2291
+ // product_status.fixed + OpenVEX status:'fixed' propagate into the
2292
+ // bundle.
2156
2293
  submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
2157
2294
  } catch (e) {
2158
2295
  return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
@@ -2191,14 +2328,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2191
2328
  // ack state by that name (`result.ack` is shorter + matches the CLI flag).
2192
2329
  if (result && runOpts.operator) result.operator = runOpts.operator;
2193
2330
 
2194
- // EE P1-6: --ack consent only counts when a jurisdiction clock is actually
2195
- // at stake — i.e. the run produced classification=detected (a real finding
2331
+ // --ack consent only counts when a jurisdiction clock is actually at
2332
+ // stake — i.e. the run produced classification=detected (a real finding
2196
2333
  // that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
2197
- // not-detected or inconclusive run, persisting the consent silently was
2198
- // misleading: the attestation file recorded operator acknowledgement of
2199
- // a clock that never started. Now: surface the ack state in the run body
2200
- // either way so operators see what happened, but only persist
2201
- // `operator_consent` into the attestation when classification === detected.
2334
+ // not-detected or inconclusive run, persisting the consent would record
2335
+ // operator acknowledgement of a clock that never started. Surface the
2336
+ // ack state in the run body either way so operators see what happened,
2337
+ // but only persist `operator_consent` into the attestation when
2338
+ // classification === detected.
2202
2339
  const detectClassification = result && result.phases && result.phases.detect
2203
2340
  ? result.phases.detect.classification
2204
2341
  : null;
@@ -2223,7 +2360,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2223
2360
  directiveId: result.directive_id,
2224
2361
  evidenceHash: result.evidence_hash,
2225
2362
  operator: runOpts.operator,
2226
- // EE P1-6: gate consent persistence on classification=detected.
2363
+ // Gate consent persistence on classification=detected.
2227
2364
  operatorConsent: consentApplies ? runOpts.operator_consent : null,
2228
2365
  submission,
2229
2366
  runOpts,
@@ -2231,10 +2368,17 @@ function cmdRun(runner, args, runOpts, pretty) {
2231
2368
  filename: "attestation.json",
2232
2369
  });
2233
2370
  if (!persistResult.ok) {
2234
- // Session-id collision without --force-overwrite. Refuse, surface the
2235
- // existing path so the operator can decide, and emit JSON to stderr
2236
- // matching the unified error shape. Exit non-zero a silent overwrite
2237
- // is a tamper-evidence violation.
2371
+ // Session-id collision without --force-overwrite, OR --force-overwrite
2372
+ // lost the lockfile race. Refuse, surface the existing path so the
2373
+ // operator can decide, emit JSON to stderr matching the unified error
2374
+ // shape. Exit non-zero — a silent overwrite is a tamper-evidence
2375
+ // violation. v0.12.14: exitCode + return instead of process.exit so
2376
+ // the stderr line drains under piped CI consumers.
2377
+ //
2378
+ // When persistAttestation lost the lockfile race it pinned
2379
+ // process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
2380
+ // overwrite that with 3 — preserve the exit-8 contract callers depend
2381
+ // on to distinguish lock-busy from collision.
2238
2382
  const err = {
2239
2383
  ok: false,
2240
2384
  error: persistResult.error,
@@ -2242,10 +2386,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2242
2386
  hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
2243
2387
  verb: "run",
2244
2388
  };
2245
- // v0.12.14: exitCode + return instead of process.exit
2246
- // so the stderr line drains under piped CI consumers.
2389
+ if (persistResult.lock_contention) {
2390
+ err.lock_contention = true;
2391
+ err.exit_code = 8;
2392
+ }
2247
2393
  process.stderr.write(JSON.stringify(err) + "\n");
2248
- process.exitCode = 3;
2394
+ if (!persistResult.lock_contention) {
2395
+ process.exitCode = 3;
2396
+ }
2249
2397
  return;
2250
2398
  }
2251
2399
  if (persistResult.prior_session_id) {
@@ -2259,12 +2407,11 @@ function cmdRun(runner, args, runOpts, pretty) {
2259
2407
  }
2260
2408
 
2261
2409
  if (result && result.ok === false) {
2262
- // F19: align preflight-halt exit code between `run --ci` and `ci`.
2263
- // Pre-fix `run --ci` exited 1 (FRAMEWORK_ERROR) while `ci` on the same
2264
- // halt exited 4 (BLOCKED). Now both use 4 when --ci is in effect, so
2265
- // operators can wire one set of exit-code expectations regardless of
2266
- // which verb they call. Without --ci the legacy exit 1 is preserved
2267
- // (ok:false bodies are framework signals when no CI gating is asked for).
2410
+ // Align preflight-halt exit code between `run --ci` and `ci`: both use
2411
+ // 4 (BLOCKED) when --ci is in effect so operators can wire one set of
2412
+ // exit-code expectations regardless of which verb they call. Without
2413
+ // --ci the legacy exit 1 is preserved (ok:false bodies are framework
2414
+ // signals when no CI gating is requested).
2268
2415
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
2269
2416
  process.exitCode = args.ci ? 4 : 1;
2270
2417
  return;
@@ -2472,9 +2619,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2472
2619
  const top = rwep?.threshold?.escalate ?? "n/a";
2473
2620
  const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
2474
2621
  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`);
2475
- // F11: surface --diff-from-latest verdict in the human renderer. Pre-fix
2476
- // operators had to add --json to see whether the run drifted from the
2477
- // previous attestation. Now one summary line follows the classification.
2622
+ // F11: surface --diff-from-latest verdict in the human renderer so
2623
+ // operators see whether the run drifted from the previous attestation
2624
+ // without adding --json. One summary line follows the classification.
2625
+ // Marker text is grep-matched by tests/audit-i-l-m-fixes.test.js F11.
2478
2626
  // - unchanged: same evidence_hash as prior → reassuring single line.
2479
2627
  // - drifted: evidence differs → loud DRIFTED marker.
2480
2628
  // - no_prior_attestation_for_playbook: no line — don't clutter the
@@ -2552,7 +2700,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2552
2700
  * inconclusive findings + visibility gaps) when no --evidence is given.
2553
2701
  */
2554
2702
  /**
2555
- * F13: collapse per-playbook notification_actions into a deduped rollup.
2703
+ * Collapse per-playbook notification_actions into a deduped rollup.
2556
2704
  * Multi-playbook runs frequently surface the same jurisdiction clock from
2557
2705
  * 5-10 contributing playbooks (every EU-touching playbook starts a fresh
2558
2706
  * NIS2 Art.23 24h clock). Operators were drafting one notification per
@@ -2587,12 +2735,11 @@ function buildJurisdictionClockRollup(results) {
2587
2735
  existing.deadline = n.deadline;
2588
2736
  }
2589
2737
  } else {
2590
- // R-F11: emit `obligation` (the field name the CHANGELOG v0.12.16
2591
- // entry promised) AND retain `obligation_ref` as a kept-name alias
2738
+ // Emit `obligation` and retain `obligation_ref` as a kept-name alias
2592
2739
  // for any consumer that already parses the older shape. The dedupe
2593
2740
  // key still keys on n.obligation_ref since that's the field
2594
2741
  // notification-action stubs carry; the rollup body just exposes
2595
- // both names so the documented contract is truthful.
2742
+ // both names.
2596
2743
  const obligation = n.obligation_ref || null;
2597
2744
  m.set(key, {
2598
2745
  jurisdiction: n.jurisdiction || null,
@@ -2646,13 +2793,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2646
2793
  if (!entryPath.startsWith(resolvedDir + path.sep)) {
2647
2794
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
2648
2795
  }
2649
- // R-F12: the path.resolve check above only catches `..` traversal in
2650
- // the joined path; fs.readFileSync(entryPath) still follows symlinks,
2651
- // so a `<pb-id>.json -> /etc/shadow` symlink inside the dir would
2652
- // happily slurp the target. lstat is symlink-aware (it does NOT
2653
- // follow); refuse anything that's not a regular file. Defense in
2654
- // depth on top of the readdir filter — a junction (Windows) or
2655
- // bind-mount can shape-shift in between filter and read.
2796
+ // The path.resolve check above only catches `..` traversal in the
2797
+ // joined path; fs.readFileSync(entryPath) still follows symlinks, so
2798
+ // a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
2799
+ // slurp the target. lstat is symlink-aware (it does NOT follow);
2800
+ // refuse anything that's not a regular file. Defense in depth on top
2801
+ // of the readdir filter — a junction (Windows) or bind-mount can
2802
+ // shape-shift in between filter and read.
2656
2803
  let lst;
2657
2804
  try { lst = fs.lstatSync(entryPath); }
2658
2805
  catch (e) {
@@ -2664,9 +2811,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2664
2811
  if (!lst.isFile()) {
2665
2812
  return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2666
2813
  }
2667
- // EE P1-5: Windows directory junctions are reparse-point dirs that
2814
+ // Windows directory junctions are reparse-point dirs that
2668
2815
  // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
2669
- // ordinary directories). They bypass the symlink refusal above. Use
2816
+ // ordinary directories), bypassing the symlink refusal above. Use
2670
2817
  // realpathSync to resolve the entry and confirm it still lives under
2671
2818
  // the resolved evidence-dir — the realpath approach is portable
2672
2819
  // (catches POSIX symlinks too, defense in depth) and works regardless
@@ -2683,10 +2830,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2683
2830
  pretty
2684
2831
  );
2685
2832
  }
2686
- // EE P1-5 (hardlink defense in depth): no clean cross-platform refusal
2687
- // exists — hardlinks are indistinguishable from regular files at the
2688
- // inode level. Surface a stderr warning when nlink > 1 so the operator
2689
- // is aware a second name may point at the same file. Not a refusal —
2833
+ // Hardlink defense in depth: no clean cross-platform refusal exists —
2834
+ // hardlinks are indistinguishable from regular files at the inode
2835
+ // level. Surface a stderr warning when nlink > 1 so the operator is
2836
+ // aware a second name may point at the same file. Not a refusal —
2690
2837
  // legitimate use cases (atomic rename, package-manager dedup) produce
2691
2838
  // nlink > 1 without malicious intent.
2692
2839
  if (lst.nlink > 1) {
@@ -2714,6 +2861,29 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2714
2861
 
2715
2862
  const result = runner.run(id, directiveId, submission, perRunOpts);
2716
2863
 
2864
+ // Per-playbook --ack gating: consent only counts when a jurisdiction
2865
+ // clock is actually at stake on THIS playbook's verdict — i.e. its
2866
+ // detect.classification === 'detected'. Without this gate, a single
2867
+ // --ack on a run-all invocation would persist explicit consent into
2868
+ // every playbook's attestation regardless of whether that playbook's
2869
+ // run started a clock. The `ack_skipped_reason` surface mirrors cmdRun
2870
+ // so consumers see exactly which playbooks consumed the ack.
2871
+ const perDetectClassification = result && result.phases && result.phases.detect
2872
+ ? result.phases.detect.classification
2873
+ : null;
2874
+ const perConsentApplies =
2875
+ !!perRunOpts.operator_consent && perDetectClassification === "detected";
2876
+ if (result && perRunOpts.operator_consent) {
2877
+ result.operator_consent = perRunOpts.operator_consent;
2878
+ result.ack = !!perRunOpts.operator_consent.explicit;
2879
+ result.ack_applied = perConsentApplies;
2880
+ if (!perConsentApplies) {
2881
+ result.ack_skipped_reason = `classification=${perDetectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2882
+ }
2883
+ } else if (result) {
2884
+ result.ack = false;
2885
+ }
2886
+
2717
2887
  // Persist per-playbook attestation under the shared session.
2718
2888
  if (result && result.ok) {
2719
2889
  const persisted = persistAttestation({
@@ -2722,7 +2892,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2722
2892
  directiveId,
2723
2893
  evidenceHash: result.evidence_hash,
2724
2894
  operator: perRunOpts.operator,
2725
- operatorConsent: perRunOpts.operator_consent,
2895
+ // Gate consent persistence on this playbook's classification, not
2896
+ // on the aggregate run's --ack presence.
2897
+ operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
2726
2898
  submission,
2727
2899
  runOpts: perRunOpts,
2728
2900
  forceOverwrite: !!args["force-overwrite"],
@@ -2740,12 +2912,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2740
2912
  results.push(result);
2741
2913
  }
2742
2914
 
2743
- // F13: dedupe jurisdiction-clock notification actions across all playbook
2744
- // results into a single rollup. Pre-fix a 13-playbook multi-run with 8
2745
- // contributors of "EU NIS2 Art.23 24h" produced 8 separate entries, so
2746
- // operators drafted 8 NIS2 notifications when one was sufficient. Per-
2747
- // playbook entries are preserved on individual results; this rollup is
2748
- // additive — keyed on (jurisdiction, regulation, obligation_ref,
2915
+ // Dedupe jurisdiction-clock notification actions across all playbook
2916
+ // results into a single rollup. Without this, a 13-playbook multi-run
2917
+ // with 8 contributors of "EU NIS2 Art.23 24h" produces 8 separate
2918
+ // entries and operators draft 8 NIS2 notifications when one suffices.
2919
+ // Per-playbook entries are preserved on individual results; this rollup
2920
+ // is additive — keyed on (jurisdiction, regulation, obligation_ref,
2749
2921
  // window_hours) — with a triggered_by_playbooks[] list so operators see
2750
2922
  // which playbooks contributed.
2751
2923
  const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
@@ -2779,19 +2951,14 @@ function cmdIngest(runner, args, runOpts, pretty) {
2779
2951
  // `ingest` matches the AGENTS.md ingest contract. The submission JSON may
2780
2952
  // carry playbook_id + directive_id; --domain/--directive flags override.
2781
2953
  let submission = {};
2782
- // F4: auto-detect piped stdin (parity with cmdRun). Without this,
2783
- // `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
2784
- // because args.evidence stayed undefined and the routing JSON never got
2785
- // read. Mirrors the cmdRun behavior at line 1614.
2786
- // R-F3: use truthy `!process.stdin.isTTY` rather than `=== false`. On
2787
- // Windows MSYS bash, isTTY is `undefined` for piped streams — the
2788
- // strict `=== false` check failed and ingest silently treated the
2789
- // routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
2790
- // detects piped stdin") was a no-op on Windows pre-fix.
2791
- //
2792
- // EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
2793
- // Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
2794
- // would otherwise block here forever on the readFileSync(0) call.
2954
+ // Auto-detect piped stdin (parity with cmdRun) so
2955
+ // `echo '{...}' | exceptd ingest` reads the routing JSON instead of
2956
+ // failing with "no playbook resolved" because args.evidence stays
2957
+ // undefined.
2958
+ // Route stdin auto-detection through hasReadableStdin() (see cmdRun for
2959
+ // rationale). Wrapped-stdin test harnesses (Mocha/Jest, Docker
2960
+ // stdin-passthrough) would otherwise block here forever on the
2961
+ // readFileSync(0) call when isTTY === undefined.
2795
2962
  if (!args.evidence && hasReadableStdin()) {
2796
2963
  args.evidence = "-";
2797
2964
  }
@@ -2830,21 +2997,42 @@ function cmdIngest(runner, args, runOpts, pretty) {
2830
2997
  // calls with the same session-id silently clobbered the audit trail and no
2831
2998
  // .sig sidecar was written.
2832
2999
  if (result && result.ok && result.session_id) {
3000
+ // Mirror cmdRun / cmdRunMulti: gate operator_consent persistence on
3001
+ // classification === 'detected'. --ack is meaningful only when a
3002
+ // jurisdiction clock is at stake; persisting consent on a
3003
+ // not-detected ingest forges audit-trail consent for a clock that
3004
+ // never started.
3005
+ const ingestClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
3006
+ const ingestConsentApplies = ingestClassification === "detected";
3007
+ if (runOpts.operator_consent && !ingestConsentApplies) {
3008
+ result.ack = true;
3009
+ result.ack_applied = false;
3010
+ result.ack_skipped_reason = `classification=${ingestClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
3011
+ }
2833
3012
  const persisted = persistAttestation({
2834
3013
  sessionId: result.session_id,
2835
3014
  playbookId: result.playbook_id,
2836
3015
  directiveId: result.directive_id,
2837
3016
  evidenceHash: result.evidence_hash,
2838
3017
  operator: runOpts.operator,
2839
- operatorConsent: runOpts.operator_consent,
3018
+ operatorConsent: ingestConsentApplies ? runOpts.operator_consent : null,
2840
3019
  submission: cleanedSubmission,
2841
3020
  runOpts,
2842
3021
  forceOverwrite: !!args["force-overwrite"],
2843
3022
  filename: "attestation.json",
2844
3023
  });
2845
3024
  if (!persisted.ok) {
2846
- // Surface the collision; do not silently clobber.
2847
- return emitError(persisted.error, { session_id: result.session_id, existing_path: persisted.existingPath }, pretty);
3025
+ // Surface the collision; do not silently clobber. Preserve
3026
+ // LOCK_CONTENTION exit 8 set by persistAttestation when
3027
+ // --force-overwrite hit the lockfile race.
3028
+ const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
3029
+ if (persisted.lock_contention) {
3030
+ ctx.lock_contention = true;
3031
+ ctx.exit_code = 8;
3032
+ process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
3033
+ return;
3034
+ }
3035
+ return emitError(persisted.error, ctx, pretty);
2848
3036
  }
2849
3037
  if (persisted.prior_session_id) {
2850
3038
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
@@ -2991,21 +3179,21 @@ function persistAttestation(args) {
2991
3179
  existingPath: path.relative(process.cwd(), filePath),
2992
3180
  };
2993
3181
  }
2994
- // T P1-2: serialize the read-prior + write-new sequence behind a
2995
- // lockfile so concurrent --force-overwrite invocations against the
2996
- // same session-id slot do not degrade to last-write-wins. Pattern
2997
- // matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
2998
- // .lock file with bounded retry, PID-liveness check on contention,
2999
- // mtime fallback for orphaned lockfiles.
3000
- // DD P1-2: MAX_RETRIES capped at 10 (was 50). persistAttestation is a
3001
- // sync function called from sync callers throughout the CLI, so the
3002
- // wait loop must busy-spin (no event-loop yield available). At 50
3003
- // retries × ~200ms backoff per spin the worst case was ~10s of pegged-
3004
- // CPU + frozen-event-loop stall under attestation contention. Capping
3005
- // at 10 bounds the freeze at ~1-2s; beyond that callers receive the
3006
- // LOCK_CONTENTION sentinel on the result object and can retry from the
3007
- // outside without holding the CPU. Async refactor of persistAttestation
3008
- // + every caller is a v0.13.0 candidate.
3182
+ // Serialize the read-prior + write-new sequence behind a lockfile so
3183
+ // concurrent --force-overwrite invocations against the same session-id
3184
+ // slot do not degrade to last-write-wins. Pattern matches
3185
+ // withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling .lock file
3186
+ // with bounded retry, PID-liveness check on contention, mtime fallback
3187
+ // for orphaned lockfiles.
3188
+ // DD P1-2: MAX_RETRIES is capped at 10. persistAttestation is sync and
3189
+ // called from sync callers, so the wait loop must busy-spin (no
3190
+ // event-loop yield available). A larger bound would peg the CPU and
3191
+ // freeze the event loop for multiple seconds under attestation
3192
+ // contention. Capping at 10 bounds the freeze at ~1-2s; beyond that
3193
+ // callers receive the LOCK_CONTENTION sentinel on the result object
3194
+ // and can retry from the outside without holding the CPU. Async
3195
+ // refactor of persistAttestation + every caller is a v0.13.0
3196
+ // candidate.
3009
3197
  const lockPath = filePath + ".lock";
3010
3198
  const MAX_RETRIES = 10;
3011
3199
  const STALE_LOCK_MS = 30_000;
@@ -3044,16 +3232,25 @@ function persistAttestation(args) {
3044
3232
  }
3045
3233
  }
3046
3234
  if (!acquired) {
3047
- // DD P1-2: lock_contention sentinel so callers can distinguish a
3048
- // genuine lock-busy condition (retry-from-outside is the right move)
3049
- // from a hard failure (write error, permission denial). The sync
3050
- // spin budget was bounded above so we hit this return after ~1-2s
3051
- // of contention rather than the prior ~10s.
3235
+ // Surface lock_contention as a distinct sentinel so callers can
3236
+ // distinguish a genuine lock-busy condition (retry-from-outside is
3237
+ // the right move) from a hard failure (write error, permission
3238
+ // denial). The sync spin budget is bounded above so this return
3239
+ // fires after ~1-2s of contention.
3240
+ //
3241
+ // emit() auto-maps any ok:false body to process.exitCode = 1 (only
3242
+ // when the current value is still 0). Pin process.exitCode = 8 HERE
3243
+ // before the caller hands the body to emit(); emit() preserves the
3244
+ // already-non-zero value. Exit code 8 is reserved exclusively for
3245
+ // LOCK_CONTENTION (attestation persist); see the exit-code table in
3246
+ // printGlobalHelp().
3247
+ process.exitCode = 8;
3052
3248
  return {
3053
3249
  ok: false,
3054
3250
  error: `LOCK_CONTENTION: Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts (~1-2s of contention). Retry the operation; if it persists, inspect the lockfile for a stale holder.`,
3055
3251
  existingPath: path.relative(process.cwd(), filePath),
3056
3252
  lock_contention: true,
3253
+ exit_code: 8,
3057
3254
  };
3058
3255
  }
3059
3256
  try {
@@ -3091,7 +3288,7 @@ function persistAttestation(args) {
3091
3288
  * from "the .sig file was deleted by an attacker."
3092
3289
  */
3093
3290
  /**
3094
- * C: byte-stability normalize() for the attestation pipeline.
3291
+ * Byte-stability normalize() for the attestation pipeline.
3095
3292
  * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
3096
3293
  * normalize() implementations in lib/sign.js, lib/verify.js,
3097
3294
  * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
@@ -3117,13 +3314,13 @@ function maybeSignAttestation(filePath) {
3117
3314
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
3118
3315
  // verifier doesn't trust.
3119
3316
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3120
- // C: normalize attestation bytes before sign — strip leading
3121
- // UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
3317
+ // Normalize attestation bytes before sign — strip leading UTF-8 BOM +
3318
+ // collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
3122
3319
  // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
3123
3320
  // attestation file lives on disk under .exceptd/ and can pick up CRLF
3124
3321
  // through git-attribute / editor round-trips on Windows; without
3125
3322
  // normalization the sign/verify pair diverges on the same logical content.
3126
- // The byte-stability contract is now five sites; tests/normalize-contract
3323
+ // The byte-stability contract spans five sites; tests/normalize-contract
3127
3324
  // .test.js enforces byte-identical output across all of them.
3128
3325
  const rawContent = fs.readFileSync(filePath, "utf8");
3129
3326
  const content = normalizeAttestationBytes(rawContent);
@@ -3147,20 +3344,24 @@ function maybeSignAttestation(filePath) {
3147
3344
  key: privateKey,
3148
3345
  dsaEncoding: "ieee-p1363",
3149
3346
  });
3347
+ // The sidecar's Ed25519 signature covers ONLY the attestation file
3348
+ // bytes. Fields that travel inside the .sig but are NOT in the signed
3349
+ // message are replay-rewrite trivial: an attacker who can write the
3350
+ // directory can mutate them without invalidating the signature. The
3351
+ // sidecar therefore carries only the algorithm tag, the Ed25519
3352
+ // signature payload, and an explanatory note — no `signed_at`,
3353
+ // `signs_path`, or `signs_sha256`. Operators reading freshness use
3354
+ // filesystem mtime; the attestation file's `captured_at` field is
3355
+ // what's signed.
3150
3356
  fs.writeFileSync(sigPath, JSON.stringify({
3151
3357
  algorithm: "Ed25519",
3152
3358
  signature_base64: sig.toString("base64"),
3153
- signed_at: new Date().toISOString(),
3154
- signs_path: path.basename(filePath),
3155
- signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
3359
+ note: "Ed25519 signature covers the attestation file bytes only. Use filesystem mtime for freshness; use the attestation's `captured_at` for the signed timestamp.",
3156
3360
  }, null, 2));
3157
3361
  } else {
3158
3362
  fs.writeFileSync(sigPath, JSON.stringify({
3159
3363
  algorithm: "unsigned",
3160
3364
  signed: false,
3161
- signed_at: null,
3162
- signs_path: path.basename(filePath),
3163
- signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
3164
3365
  note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
3165
3366
  }, null, 2));
3166
3367
  }
@@ -3244,6 +3445,12 @@ function walkAttestationDir(root, opts, candidates) {
3244
3445
  try {
3245
3446
  const p = path.join(sdir, f);
3246
3447
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
3448
+ // Replay records (kind: 'replay') are an audit trail of force-replay
3449
+ // overrides, not a separate attestation. They have no captured_at /
3450
+ // evidence_hash and must not surface as candidates for --latest.
3451
+ // Gate on the parsed kind so a renamed file cannot smuggle a replay
3452
+ // record into the listing.
3453
+ if (j && j.kind === "replay") continue;
3247
3454
  if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
3248
3455
  if (opts.since && (j.captured_at || "") < opts.since) continue;
3249
3456
  if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
@@ -3254,14 +3461,14 @@ function walkAttestationDir(root, opts, candidates) {
3254
3461
  }
3255
3462
 
3256
3463
  /**
3257
- * F10: factored Ed25519-sidecar verification used by both `attest verify`
3258
- * and `reattest`. Returns { file, signed, verified, reason } for a given
3464
+ * Factored Ed25519-sidecar verification used by both `attest verify` and
3465
+ * `reattest`. Returns { file, signed, verified, reason } for a given
3259
3466
  * attestation file path.
3260
3467
  *
3261
- * Pre-fix, cmdReattest read attestation.json via JSON.parse with no
3262
- * authenticity check. A tampered attestation was silently consumed and the
3263
- * drift verdict was computed against forged input. Now cmdReattest calls
3264
- * this and refuses on verify-fail unless --force-replay is set.
3468
+ * Callers must check `signed && verified` before consuming the
3469
+ * attestation. cmdReattest refuses to replay on verify-fail unless
3470
+ * --force-replay is set, so a tampered attestation cannot silently feed
3471
+ * forged input into the drift verdict.
3265
3472
  */
3266
3473
  function verifyAttestationSidecar(attFile) {
3267
3474
  const crypto = require("crypto");
@@ -3287,7 +3494,7 @@ function verifyAttestationSidecar(attFile) {
3287
3494
  let sigDoc;
3288
3495
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
3289
3496
  catch (e) {
3290
- // Audit AA P1-2: a corrupt-JSON sidecar is observationally indistinguishable
3497
+ // a corrupt-JSON sidecar is observationally indistinguishable
3291
3498
  // from sidecar tamper — an attacker who can rewrite attestation.json can
3292
3499
  // also truncate / mangle the .sig file. Surface as a distinct
3293
3500
  // tamper-class reason so callers can require --force-replay. Pre-fix,
@@ -3303,7 +3510,7 @@ function verifyAttestationSidecar(attFile) {
3303
3510
  };
3304
3511
  }
3305
3512
  if (sigDoc.algorithm === "unsigned") {
3306
- // Audit AA P1-1: `algorithm: "unsigned"` is only legitimate when written
3513
+ // `algorithm: "unsigned"` is only legitimate when written
3307
3514
  // by maybeSignAttestation() at attestation-creation time on a host
3308
3515
  // WITHOUT .keys/private.pem. If the verifying host HAS a private key,
3309
3516
  // an "unsigned" sidecar is a substitution attack: tamper attestation.json
@@ -3321,15 +3528,33 @@ function verifyAttestationSidecar(attFile) {
3321
3528
  }
3322
3529
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3323
3530
  }
3531
+ // Strict algorithm check. A branch on `=== "unsigned"` alone would let
3532
+ // null, undefined, "RSA-PSS", arrays, etc. fall through to crypto.verify
3533
+ // with default Ed25519 args — which can either succeed against
3534
+ // wrong-algorithm signature bytes accidentally (an attacker who can
3535
+ // write the sidecar replays an existing Ed25519 signature under a
3536
+ // downgrade-bait algorithm tag) or throw a generic verify error.
3537
+ // Refuse anything that isn't exactly "Ed25519" or "unsigned" with a
3538
+ // structured tamper class so callers can route the refusal through the
3539
+ // same exit-6 path as other tamper events.
3540
+ if (sigDoc.algorithm !== "Ed25519") {
3541
+ return {
3542
+ file: attFile,
3543
+ signed: false,
3544
+ verified: false,
3545
+ reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
3546
+ tamper_class: "algorithm-unsupported",
3547
+ };
3548
+ }
3324
3549
  if (!pubKey) {
3325
3550
  return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
3326
3551
  }
3327
3552
  let content;
3328
3553
  try {
3329
3554
  const raw = fs.readFileSync(attFile, "utf8");
3330
- // C: apply the same normalize() used by the signer so the
3331
- // verify path is byte-stable across CRLF / BOM churn (Windows checkout
3332
- // with core.autocrlf=true, editor round-trips, git-attributes flips).
3555
+ // Apply the same normalize() used by the signer so the verify path is
3556
+ // byte-stable across CRLF / BOM churn (Windows checkout with
3557
+ // core.autocrlf=true, editor round-trips, git-attributes flips).
3333
3558
  content = normalizeAttestationBytes(raw);
3334
3559
  }
3335
3560
  catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
@@ -3349,13 +3574,14 @@ function verifyAttestationSidecar(attFile) {
3349
3574
  }
3350
3575
 
3351
3576
  function cmdReattest(runner, args, runOpts, pretty) {
3352
- // F29: --since ISO-8601 validation parity with `attest list --since`
3353
- // (already fixed in v0.12.12). Pre-fix, an invalid date silently passed
3354
- // through to walkAttestationDir, where the lexical comparison either
3355
- // matched all or none unpredictably.
3577
+ const crypto = require("crypto");
3578
+ // Validate --since as ISO-8601, mirroring `attest list --since`. An
3579
+ // invalid date would otherwise pass through to walkAttestationDir, where
3580
+ // the lexical comparison either matches all or none unpredictably.
3356
3581
  if (args.since != null) {
3357
- // R-F10: regex BEFORE Date.parse — bare integers like "99" would
3358
- // otherwise parse as the year 1999 and silently filter wrong eras.
3582
+ // ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
3583
+ // would otherwise parse as the year 1999 and silently filter wrong
3584
+ // eras.
3359
3585
  const sinceErr = validateIsoSince(args.since);
3360
3586
  if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
3361
3587
  }
@@ -3379,24 +3605,33 @@ function cmdReattest(runner, args, runOpts, pretty) {
3379
3605
  return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
3380
3606
  }
3381
3607
 
3382
- // F10: verify the .sig sidecar BEFORE consuming the prior attestation.
3383
- // Pre-fix, a tampered attestation.json was silently parsed and the drift
3384
- // verdict was computed against forged input. Now: refuse on verify-fail
3608
+ // Verify the .sig sidecar BEFORE consuming the prior attestation. A
3609
+ // tampered attestation.json would otherwise be silently parsed and the
3610
+ // drift verdict computed against forged input. Refuse on verify-fail
3385
3611
  // with exit 6 (TAMPERED) unless --force-replay is explicitly set.
3386
3612
  // Unsigned attestations (no private key was available at run time) emit
3387
3613
  // a stderr warning but proceed — that's an operator config issue, not
3388
3614
  // tampering. `verified === false && signed === true` is the real tamper
3389
3615
  // signal.
3390
3616
  const verify = verifyAttestationSidecar(attFile);
3391
- // Audit AA P1-1 + P1-2: collapse tamper-class detection. Any non-benign
3392
- // sidecar state (signed-but-invalid, sidecar-corrupt, unsigned-substitution)
3393
- // refuses replay unless --force-replay is set. The pre-fix shape only
3394
- // refused on `verify.signed && !verify.verified` (signed-tamper) and on
3395
- // `no .sig sidecar` (missing); corrupt-JSON sidecars and substituted
3396
- // "unsigned" sidecars on a host WITH a private key fell into the benign
3397
- // NOTE branch and replay proceeded against forged input.
3617
+ // Collapse tamper-class detection. Any non-benign sidecar state
3618
+ // (signed-but-invalid, sidecar-corrupt, unsigned-substitution) refuses
3619
+ // replay unless --force-replay is set. A predicate of only
3620
+ // `verify.signed && !verify.verified` would miss corrupt-JSON sidecars
3621
+ // and substituted "unsigned" sidecars on a host WITH a private key —
3622
+ // both of which let replay proceed against forged input.
3398
3623
  const isSignedTamper = verify.signed && !verify.verified;
3399
- const isClassTamper = !verify.signed && (verify.tamper_class === "sidecar-corrupt" || verify.tamper_class === "unsigned-substitution");
3624
+ const isClassTamper = !verify.signed && (
3625
+ verify.tamper_class === "sidecar-corrupt"
3626
+ || verify.tamper_class === "unsigned-substitution"
3627
+ // Extend tamper-class refusal to algorithm-unsupported sidecars —
3628
+ // anything other than "Ed25519" or "unsigned". Without explicit
3629
+ // refusal, a sidecar that throws inside crypto.verify (e.g.
3630
+ // signature_base64 missing on a downgrade-bait shape) emerges as
3631
+ // signed:true + verified:false through the catch block by accident.
3632
+ // The strict pre-check surfaces the class directly; refuse on it too.
3633
+ || verify.tamper_class === "algorithm-unsupported"
3634
+ );
3400
3635
  if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
3401
3636
  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`);
3402
3637
  const body = {
@@ -3441,7 +3676,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3441
3676
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3442
3677
  process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding missing .sig sidecar on ${attFile}. The replay output records sidecar_verify so the override is audit-visible.\n`);
3443
3678
  } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && !args["force-replay"]) {
3444
- // Audit AA P1-1: legitimately-unsigned attestations (written when the
3679
+ // legitimately-unsigned attestations (written when the
3445
3680
  // attesting host had no private key) require --force-replay to consume.
3446
3681
  // Pre-fix, the NOTE branch accepted them silently — which let an
3447
3682
  // attacker swap a valid .sig with the unsigned stub on a host that
@@ -3520,6 +3755,85 @@ function cmdReattest(runner, args, runOpts, pretty) {
3520
3755
  }
3521
3756
  }
3522
3757
 
3758
+ const replayedAt = new Date().toISOString();
3759
+ const sidecarVerifyClass = classifySidecarVerify(verify);
3760
+ const forceReplay = !!args["force-replay"];
3761
+
3762
+ // Persist a `replay-<isoZ>.json` record under the session directory for
3763
+ // every cmdReattest replay verdict. Without disk persistence, a
3764
+ // force-replay override emitted to stdout becomes invisible to any
3765
+ // subsequent auditor once the operator's shell closes. Each replay
3766
+ // writes a new file alongside the original attestation.json, signed via
3767
+ // the standard maybeSignAttestation path so the audit chain remains
3768
+ // tamper-evident. The file is picked up automatically by
3769
+ // `attest verify <sid>` (which iterates every *.json under the session
3770
+ // dir).
3771
+ //
3772
+ // Filename shape: ISO-8601 uses ':' which the persistAttestation regex
3773
+ // refuses; substitute ':' with '-' and keep millisecond precision so
3774
+ // multiple replays in the same second do not collide on EEXIST. The
3775
+ // resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
3776
+ const replayBaseName = "replay-" + replayedAt.replace(/:/g, "-");
3777
+ const replayBody = {
3778
+ kind: "replay",
3779
+ session_id: sessionId,
3780
+ playbook_id: prior.playbook_id,
3781
+ directive_id: prior.directive_id,
3782
+ status,
3783
+ prior_evidence_hash: priorHash,
3784
+ replay_evidence_hash: newHash,
3785
+ prior_captured_at: prior.captured_at,
3786
+ replayed_at: replayedAt,
3787
+ replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3788
+ replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3789
+ sidecar_verify: verify,
3790
+ sidecar_verify_class: sidecarVerifyClass,
3791
+ force_replay: forceReplay,
3792
+ };
3793
+ let replayPersisted = null;
3794
+ let replayPath = null;
3795
+ try {
3796
+ // Retry on EEXIST: two concurrent reattests sharing the same
3797
+ // millisecond timestamp would collide on the base name. Append a short
3798
+ // random suffix until O_EXCL accepts the write or the cap is exhausted.
3799
+ const dir = path.dirname(attFile);
3800
+ const MAX_SUFFIX_TRIES = 8;
3801
+ let written = false;
3802
+ let lastErr = null;
3803
+ for (let i = 0; i < MAX_SUFFIX_TRIES; i++) {
3804
+ const suffix = i === 0 ? "" : "-" + crypto.randomBytes(3).toString("hex");
3805
+ const candidate = path.join(dir, replayBaseName + suffix + ".json");
3806
+ try {
3807
+ fs.writeFileSync(candidate, JSON.stringify(replayBody, null, 2), { flag: "wx" });
3808
+ replayPath = candidate;
3809
+ written = true;
3810
+ break;
3811
+ } catch (e) {
3812
+ lastErr = e;
3813
+ if (!e || e.code !== "EEXIST") throw e;
3814
+ }
3815
+ }
3816
+ if (!written) throw lastErr || new Error("replay-record write: EEXIST after " + MAX_SUFFIX_TRIES + " attempts");
3817
+ replayPersisted = { ok: true, path: replayPath, sidecar_signed: true };
3818
+ } catch (e) {
3819
+ // Non-fatal — stdout emit is the operator's primary surface; a
3820
+ // disk-persistence failure shouldn't mask the verdict. Surface the
3821
+ // condition in the response body so an operator-side audit pipeline
3822
+ // can re-run the persist later.
3823
+ replayPersisted = { ok: false, error: String((e && e.message) || e) };
3824
+ }
3825
+ if (replayPersisted && replayPersisted.ok && replayPath) {
3826
+ // Sidecar signing is best-effort: the unsigned replay record on disk
3827
+ // is still a valid audit-trail entry. Split from the write try{} so a
3828
+ // sign-time failure doesn't mask a successful write.
3829
+ try {
3830
+ maybeSignAttestation(replayPath);
3831
+ } catch (e) {
3832
+ replayPersisted.sidecar_signed = false;
3833
+ replayPersisted.sidecar_sign_error = String((e && e.message) || e);
3834
+ }
3835
+ }
3836
+
3523
3837
  emit({
3524
3838
  ok: true,
3525
3839
  verb: "reattest",
@@ -3530,13 +3844,13 @@ function cmdReattest(runner, args, runOpts, pretty) {
3530
3844
  prior_evidence_hash: priorHash,
3531
3845
  replay_evidence_hash: newHash,
3532
3846
  prior_captured_at: prior.captured_at,
3533
- replayed_at: new Date().toISOString(),
3847
+ replayed_at: replayedAt,
3534
3848
  replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3535
3849
  replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3536
- // F10: persist the sidecar verify result + the force-replay flag so the
3850
+ // Persist the sidecar verify result + the force-replay flag so the
3537
3851
  // audit trail records whether the replay was authenticated input.
3538
3852
  sidecar_verify: verify,
3539
- // Audit AA P1-1: emit a one-token classification label alongside the
3853
+ // emit a one-token classification label alongside the
3540
3854
  // full sidecar_verify object so log scrapers / dashboards can filter on
3541
3855
  // override events without parsing reason strings. Values:
3542
3856
  // 'verified' — Ed25519 sidecar verified
@@ -3544,16 +3858,22 @@ function cmdReattest(runner, args, runOpts, pretty) {
3544
3858
  // 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
3545
3859
  // 'unsigned-substitution'— "unsigned" sidecar on a host with private key
3546
3860
  // (substitution attack signal)
3861
+ // 'algorithm-unsupported'— sidecar algorithm field is neither "Ed25519"
3862
+ // nor "unsigned" (downgrade-bait substitution)
3547
3863
  // 'explicitly-unsigned' — legitimately-unsigned attestation
3548
3864
  // 'no-sidecar' — sidecar file absent
3549
3865
  // 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
3550
- sidecar_verify_class: classifySidecarVerify(verify),
3551
- force_replay: !!args["force-replay"],
3866
+ sidecar_verify_class: sidecarVerifyClass,
3867
+ force_replay: forceReplay,
3868
+ // Surface the persisted replay-record path (or persistence failure
3869
+ // reason) so an auditor reading the CLI response can locate the
3870
+ // on-disk artifact without re-deriving the filename.
3871
+ replay_persisted: replayPersisted,
3552
3872
  }, pretty);
3553
3873
  }
3554
3874
 
3555
3875
  /**
3556
- * Audit AA P1-1: map a verifyAttestationSidecar() result to a one-token
3876
+ * map a verifyAttestationSidecar() result to a one-token
3557
3877
  * classification label. The label is persisted alongside the full
3558
3878
  * sidecar_verify object so auditors can filter override events by class
3559
3879
  * without regexing the human-readable reason string.
@@ -3564,6 +3884,9 @@ function classifySidecarVerify(verify) {
3564
3884
  if (verify.signed && !verify.verified) return "tampered";
3565
3885
  if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
3566
3886
  if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
3887
+ // `algorithm-unsupported` is its own class label so log scrapers /
3888
+ // dashboards can filter downgrade-bait events without parsing the reason.
3889
+ if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
3567
3890
  if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
3568
3891
  if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
3569
3892
  if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
@@ -3595,12 +3918,12 @@ function cmdAttest(runner, args, runOpts, pretty) {
3595
3918
  if (!sessionId) {
3596
3919
  return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
3597
3920
  }
3598
- // R-F7: distinguish "validation rejected" from "valid format but not
3599
- // found". findSessionDir() returns null for BOTH (regex-rejected ids
3600
- // collapse to the "no session dir" message), which gives operators a
3601
- // misleading error — a string with `..` or `/` looks to them like an
3602
- // existing-session lookup that failed, not a refusal. Call the same
3603
- // validator up front; emit its specific message when it throws.
3921
+ // Distinguish "validation rejected" from "valid format but not found".
3922
+ // findSessionDir() returns null for BOTH (regex-rejected ids collapse to
3923
+ // the "no session dir" message), which gives operators a misleading
3924
+ // error — a string with `..` or `/` looks to them like an existing-
3925
+ // session lookup that failed, not a refusal. Call the same validator
3926
+ // up front; emit its specific message when it throws.
3604
3927
  try { validateSessionIdForRead(sessionId); }
3605
3928
  catch (e) {
3606
3929
  return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
@@ -3611,13 +3934,24 @@ function cmdAttest(runner, args, runOpts, pretty) {
3611
3934
  }
3612
3935
 
3613
3936
  const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
3614
- const attestations = files.map(f => {
3615
- try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
3616
- catch { return null; }
3617
- }).filter(Boolean);
3937
+ // Partition session-dir JSON files by parsed `kind` field. Replay records
3938
+ // (written by `cmdReattest`) live alongside attestations under the same
3939
+ // session directory but represent audit-trail entries, not separate
3940
+ // sessions. Gate on the parsed payload — not filename prefix — so a
3941
+ // renamed file cannot smuggle a replay into the attestations[] list.
3942
+ const attestations = [];
3943
+ const replays = [];
3944
+ for (const f of files) {
3945
+ let parsed;
3946
+ try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
3947
+ catch { continue; }
3948
+ if (!parsed) continue;
3949
+ if (parsed.kind === "replay") replays.push(parsed);
3950
+ else attestations.push(parsed);
3951
+ }
3618
3952
 
3619
3953
  if (subverb === "show") {
3620
- emit({ session_id: sessionId, attestations }, pretty);
3954
+ emit({ session_id: sessionId, attestations, attestation_replays: replays }, pretty);
3621
3955
  return;
3622
3956
  }
3623
3957
 
@@ -3632,10 +3966,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
3632
3966
  return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
3633
3967
  }
3634
3968
  const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
3635
- if (otherFiles.length === 0) {
3969
+ // Skip replay-record files: those carry `kind: 'replay'` and are
3970
+ // audit-trail entries rather than attestations. Without this gate
3971
+ // a replay file sorted ahead of attestation.json would shadow the
3972
+ // real attestation in the diff.
3973
+ let other = null;
3974
+ for (const f of otherFiles) {
3975
+ try {
3976
+ const parsed = JSON.parse(fs.readFileSync(path.join(otherDir, f), "utf8"));
3977
+ if (parsed && parsed.kind === "replay") continue;
3978
+ other = parsed;
3979
+ break;
3980
+ } catch { /* skip malformed */ }
3981
+ }
3982
+ if (!other) {
3636
3983
  return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
3637
3984
  }
3638
- const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
3639
3985
  const self = attestations[0];
3640
3986
  emit({
3641
3987
  verb: "attest diff",
@@ -3689,7 +4035,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3689
4035
  pretty
3690
4036
  );
3691
4037
  }
3692
- // Audit AA P1-1: on the verifying host, detect "unsigned" sidecar
4038
+ // on the verifying host, detect "unsigned" sidecar
3693
4039
  // substitution by checking whether .keys/private.pem is present. A
3694
4040
  // legitimately-unsigned attestation cannot coexist with a private key on
3695
4041
  // the same host — that combination is sidecar substitution (attacker
@@ -3699,7 +4045,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3699
4045
  const results = files.map(f => {
3700
4046
  const sigPath = path.join(dir, f + ".sig");
3701
4047
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
3702
- // Audit AA P1-2: wrap JSON.parse so a corrupt sidecar surfaces as a
4048
+ // wrap JSON.parse so a corrupt sidecar surfaces as a
3703
4049
  // structured tamper-class result (signed:false, verified:false,
3704
4050
  // tamper_class:"sidecar-corrupt") rather than throwing into the outer
3705
4051
  // dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
@@ -3717,7 +4063,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3717
4063
  };
3718
4064
  }
3719
4065
  if (sigDoc.algorithm === "unsigned") {
3720
- // Audit AA P1-1: substitution detection.
4066
+ // substitution detection.
3721
4067
  if (hasPrivKey) {
3722
4068
  return {
3723
4069
  file: f,
@@ -3729,9 +4075,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
3729
4075
  }
3730
4076
  return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3731
4077
  }
4078
+ // Strict algorithm check (mirrors verifyAttestationSidecar). Anything
4079
+ // that isn't exactly "Ed25519" or "unsigned" is refused as
4080
+ // tamper-class; null / "RSA-PSS" / arrays would otherwise fall through
4081
+ // to crypto.verify with Ed25519 defaults, producing either an opaque
4082
+ // verify-throw or a downgrade-bait acceptance path.
4083
+ if (sigDoc.algorithm !== "Ed25519") {
4084
+ return {
4085
+ file: f,
4086
+ signed: false,
4087
+ verified: false,
4088
+ reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
4089
+ tamper_class: "algorithm-unsupported",
4090
+ };
4091
+ }
3732
4092
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
3733
- // C: normalize before crypto.verify — mirrors the signer
3734
- // path so the verify pair is byte-stable across CRLF / BOM churn.
4093
+ // Normalize before crypto.verify — mirrors the signer path so the
4094
+ // verify pair is byte-stable across CRLF / BOM churn.
3735
4095
  const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
3736
4096
  const content = normalizeAttestationBytes(rawContent);
3737
4097
  try {
@@ -3743,23 +4103,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
3743
4103
  return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
3744
4104
  }
3745
4105
  });
3746
- // R-F1: when ANY result is signed-but-failed-verify, surface ok:false
3747
- // AND set exit 6 for parity with cmdReattest's TAMPERED code. Pre-fix,
3748
- // `attest verify` emitted {verb, session_id, results} without ok:false
3749
- // and exited 0 operators piping through `set -e` saw no failure
3750
- // signal even when an attestation had been forged. emit()'s ok:false
3751
- // → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
3752
- // from generic failure, so explicitly raise to 6 (cmdReattest's code).
4106
+ // When ANY result is signed-but-failed-verify, surface ok:false AND
4107
+ // set exit 6 for parity with cmdReattest's TAMPERED code. The
4108
+ // ok:false exitCode = 1 auto-promotion would stop at 1; tamper is
4109
+ // distinct from generic failure, so explicitly raise to 6.
3753
4110
  //
3754
- // Audit AA P1-1 + P1-2: extend the tamper predicate to cover the new
3755
- // tamper_class variants. Pre-fix the predicate was `r.signed && !r.verified`
3756
- // which missed (a) corrupt-JSON sidecars (signed:false) and (b) "unsigned"
3757
- // sidecar substitution on hosts with a private key (signed:false). Both
3758
- // are tamper-class events and must promote to exit 6.
4111
+ // The tamper predicate covers every tamper_class variant bare
4112
+ // `r.signed && !r.verified` would miss (a) corrupt-JSON sidecars
4113
+ // (signed:false) and (b) "unsigned" sidecar substitution on hosts with
4114
+ // a private key (signed:false). Both are tamper-class events and must
4115
+ // promote to exit 6.
3759
4116
  const tampered = results.some(r =>
3760
4117
  (r.signed && !r.verified)
3761
4118
  || r.tamper_class === "sidecar-corrupt"
3762
4119
  || r.tamper_class === "unsigned-substitution"
4120
+ // A sidecar whose algorithm field is not "Ed25519" or "unsigned" is
4121
+ // a downgrade-bait substitution; promote to exit 6.
4122
+ || r.tamper_class === "algorithm-unsupported"
3763
4123
  );
3764
4124
  const body = { verb: "attest verify", session_id: sessionId, results };
3765
4125
  if (tampered) {
@@ -4561,8 +4921,9 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4561
4921
  // Prior behavior silently accepted any string and lexically compared to
4562
4922
  // captured_at, producing 0-result or full-result depending on the string.
4563
4923
  if (args.since != null) {
4564
- // R-F10: regex BEFORE Date.parse — bare integers like "99" would
4565
- // otherwise parse as the year 1999 and silently filter wrong eras.
4924
+ // ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
4925
+ // would otherwise parse as the year 1999 and silently filter wrong
4926
+ // eras.
4566
4927
  const sinceErr = validateIsoSince(args.since);
4567
4928
  if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
4568
4929
  }
@@ -4583,6 +4944,11 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4583
4944
  for (const f of files) {
4584
4945
  try {
4585
4946
  const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
4947
+ // replay-<isoZ>.json records share the session dir with
4948
+ // attestation.json but are not separate sessions. Gate on the
4949
+ // parsed `kind` field rather than filename so a rename cannot
4950
+ // smuggle a replay record into the listing.
4951
+ if (j && j.kind === "replay") continue;
4586
4952
  // v0.12.14: normalized array-set filter (see top of fn).
4587
4953
  if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
4588
4954
  if (args.since && (j.captured_at || "") < args.since) continue;
@@ -4660,7 +5026,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4660
5026
  directPhase = runner.direct(playbookId, directiveId);
4661
5027
  lookPhase = runner.look(playbookId, directiveId, runOpts);
4662
5028
  } catch (e) {
4663
- // v0.12.12 (T8): process.exit(1) immediately after a stdout write can
5029
+ // process.exit(1) immediately after a stdout write can
4664
5030
  // truncate buffered output under piped consumers (same class as v0.11.10
4665
5031
  // #100). Use exitCode+return so the JSONL error frame drains. Also write
4666
5032
  // the framed error event so the stdout-only JSONL contract holds — host
@@ -4709,7 +5075,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4709
5075
  try { payload = readEvidence(args.evidence); }
4710
5076
  catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
4711
5077
  } else if (hasReadableStdin()) {
4712
- // EE P1-7: hasReadableStdin() probes via fstat before falling into
5078
+ // hasReadableStdin() probes via fstat before falling into
4713
5079
  // readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
4714
5080
  // size===0) would otherwise hang here.
4715
5081
  // Drain stdin for any evidence event.
@@ -4758,13 +5124,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4758
5124
  // or `reattest` with the returned id. Now: same persistAttestation
4759
5125
  // shape as cmdRun, so AI-facing flow round-trips cleanly.
4760
5126
  if (result.session_id) {
5127
+ // Mirror cmdRun: gate operator_consent on classification === 'detected'.
5128
+ const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
5129
+ const aiConsentApplies = aiClassification === "detected";
5130
+ if (runOpts.operator_consent && !aiConsentApplies) {
5131
+ result.ack = true;
5132
+ result.ack_applied = false;
5133
+ result.ack_skipped_reason = `classification=${aiClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
5134
+ }
4761
5135
  const persistResult = persistAttestation({
4762
5136
  sessionId: result.session_id,
4763
5137
  playbookId: result.playbook_id || playbookId,
4764
5138
  directiveId: result.directive_id || directiveId,
4765
5139
  evidenceHash: result.evidence_hash,
4766
5140
  operator: runOpts.operator,
4767
- operatorConsent: runOpts.operator_consent,
5141
+ operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
4768
5142
  submission,
4769
5143
  runOpts,
4770
5144
  forceOverwrite: !!args["force-overwrite"],
@@ -4773,12 +5147,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4773
5147
  if (!persistResult.ok && !args["force-overwrite"]) {
4774
5148
  // Collision without --force-overwrite. AI agents typically pass
4775
5149
  // unique session ids each run, so this path is rare but surface
4776
- // it cleanly via the same JSONL contract.
4777
- process.stdout.write(JSON.stringify({
5150
+ // it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
5151
+ // exit 8 set by persistAttestation when --force-overwrite hit the
5152
+ // lockfile race — don't clobber with exit 3.
5153
+ const eventBody = {
4778
5154
  event: "error", reason: persistResult.error,
4779
5155
  existing_attestation: persistResult.existingPath,
4780
- }) + "\n");
4781
- process.exitCode = 3;
5156
+ };
5157
+ if (persistResult.lock_contention) {
5158
+ eventBody.lock_contention = true;
5159
+ eventBody.exit_code = 8;
5160
+ }
5161
+ process.stdout.write(JSON.stringify(eventBody) + "\n");
5162
+ if (!persistResult.lock_contention) {
5163
+ process.exitCode = 3;
5164
+ }
4782
5165
  return;
4783
5166
  }
4784
5167
  }
@@ -4862,21 +5245,31 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4862
5245
  // too. Without this, the session_id emitted in the `done` frame
4863
5246
  // can't be resolved by `attest show / verify / diff` or `reattest`.
4864
5247
  if (result.session_id) {
5248
+ // Mirror cmdRun: gate operator_consent on classification === 'detected'.
5249
+ const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
5250
+ const aiConsentApplies = aiClassification === "detected";
4865
5251
  const persistResult = persistAttestation({
4866
5252
  sessionId: result.session_id,
4867
5253
  playbookId: result.playbook_id || playbookId,
4868
5254
  directiveId: result.directive_id || directiveId,
4869
5255
  evidenceHash: result.evidence_hash,
4870
5256
  operator: runOpts.operator,
4871
- operatorConsent: runOpts.operator_consent,
5257
+ operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
4872
5258
  submission,
4873
5259
  runOpts,
4874
5260
  forceOverwrite: !!args["force-overwrite"],
4875
5261
  filename: "attestation.json",
4876
5262
  });
4877
5263
  if (!persistResult.ok && !args["force-overwrite"]) {
4878
- writeLine({ event: "error", reason: persistResult.error,
4879
- existing_attestation: persistResult.existingPath });
5264
+ const eventBody = { event: "error", reason: persistResult.error,
5265
+ existing_attestation: persistResult.existingPath };
5266
+ if (persistResult.lock_contention) {
5267
+ eventBody.lock_contention = true;
5268
+ eventBody.exit_code = 8;
5269
+ writeLine(eventBody);
5270
+ return finish(8);
5271
+ }
5272
+ writeLine(eventBody);
4880
5273
  return finish(3);
4881
5274
  }
4882
5275
  }
@@ -5176,9 +5569,9 @@ function cmdCi(runner, args, runOpts, pretty) {
5176
5569
  const results = [];
5177
5570
  let fail = false;
5178
5571
  let failReasons = [];
5179
- // F18: track jurisdiction-clock signals separately from generic FAIL so the
5180
- // exit code can distinguish "detected/escalated" (2) from "regulatory clock
5181
- // running, operator must notify" (5). Pre-fix the two collapsed into exit 2.
5572
+ // Track jurisdiction-clock signals separately from generic FAIL so the
5573
+ // exit code can distinguish "detected/escalated" (2) from "regulatory
5574
+ // clock running, operator must notify" (5).
5182
5575
  let clockStartedFail = false;
5183
5576
  let clockStartedReasons = [];
5184
5577
 
@@ -5232,11 +5625,10 @@ function cmdCi(runner, args, runOpts, pretty) {
5232
5625
  failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
5233
5626
  }
5234
5627
  if (blockOnClock && clockStarted) {
5235
- // F18: separate "clock started" from generic FAIL. Pre-fix this collapsed
5236
- // into exit 2 (FAIL), so operators couldn't distinguish "playbook
5237
- // detected" from "regulatory clock running." Tracked separately and
5238
- // exit 5 (CLOCK_STARTED) is selected below, taking precedence over
5239
- // FAIL but not BLOCKED.
5628
+ // Separate "clock started" from generic FAIL: exit 5 (CLOCK_STARTED)
5629
+ // is selected below, taking precedence over FAIL but not BLOCKED, so
5630
+ // operators can distinguish "playbook detected" from "regulatory
5631
+ // clock running."
5240
5632
  clockStartedFail = true;
5241
5633
  clockStartedReasons.push(`${id}: jurisdiction clock started`);
5242
5634
  }
@@ -5256,7 +5648,7 @@ function cmdCi(runner, args, runOpts, pretty) {
5256
5648
  const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
5257
5649
  const totalForVerdict = results.length;
5258
5650
  const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
5259
- // F18: precedence — BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
5651
+ // Precedence: BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
5260
5652
  // CLOCK_STARTED outranks FAIL because the operator explicitly opted into
5261
5653
  // the clock gate (--block-on-jurisdiction-clock); when that gate fires,
5262
5654
  // they want the regulatory-deadline signal even if a detected finding
@@ -5312,11 +5704,11 @@ function cmdCi(runner, args, runOpts, pretty) {
5312
5704
  .filter(n => n && n.clock_started_at != null).length,
5313
5705
  framework_gap_rollup: frameworkGapRollup,
5314
5706
  framework_gap_count: frameworkGapRollup.length,
5315
- // F13: dedupe jurisdiction-clock notifications across playbooks; see
5316
- // buildJurisdictionClockRollup. Multi-playbook ci runs were producing
5317
- // one notification entry per contributing playbook (often 8+) when a
5318
- // single notification per (jurisdiction, regulation, obligation,
5319
- // window) was the right shape.
5707
+ // Dedupe jurisdiction-clock notifications across playbooks; see
5708
+ // buildJurisdictionClockRollup. Without this, multi-playbook ci runs
5709
+ // produce one notification entry per contributing playbook (often 8+)
5710
+ // when a single notification per (jurisdiction, regulation,
5711
+ // obligation, window) is the right shape.
5320
5712
  jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
5321
5713
  verdict: computedVerdict,
5322
5714
  fail_reasons: failReasons,
@@ -5380,7 +5772,7 @@ function cmdCi(runner, args, runOpts, pretty) {
5380
5772
  process.exitCode = 4;
5381
5773
  return;
5382
5774
  }
5383
- // F18: precedence BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
5775
+ // Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
5384
5776
  // --block-on-jurisdiction-clock; when a clock fires, that's the gate
5385
5777
  // result they want to see at the exit-code layer. Per-playbook detected
5386
5778
  // findings remain in the body for them to investigate.