@blamejs/exceptd-skills 0.12.20 → 0.12.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +137 -6
  2. package/bin/exceptd.js +835 -70
  3. package/data/_indexes/_meta.json +14 -14
  4. package/data/_indexes/activity-feed.json +3 -3
  5. package/data/_indexes/catalog-summaries.json +3 -3
  6. package/data/_indexes/chains.json +15 -0
  7. package/data/_indexes/jurisdiction-map.json +3 -2
  8. package/data/_indexes/section-offsets.json +175 -175
  9. package/data/_indexes/summary-cards.json +1 -1
  10. package/data/_indexes/token-budget.json +83 -83
  11. package/data/cve-catalog.json +169 -2
  12. package/data/exploit-availability.json +16 -0
  13. package/data/playbooks/ai-api.json +18 -0
  14. package/data/playbooks/containers.json +30 -0
  15. package/data/playbooks/cred-stores.json +18 -0
  16. package/data/playbooks/crypto.json +18 -0
  17. package/data/playbooks/hardening.json +26 -1
  18. package/data/playbooks/kernel.json +22 -2
  19. package/data/playbooks/mcp.json +18 -0
  20. package/data/playbooks/runtime.json +22 -1
  21. package/data/playbooks/sbom.json +18 -0
  22. package/data/playbooks/secrets.json +6 -0
  23. package/data/zeroday-lessons.json +102 -0
  24. package/lib/auto-discovery.js +9 -9
  25. package/lib/cross-ref-api.js +43 -10
  26. package/lib/cve-curation.js +4 -4
  27. package/lib/playbook-runner.js +529 -70
  28. package/lib/prefetch.js +3 -3
  29. package/lib/refresh-external.js +13 -2
  30. package/lib/refresh-network.js +22 -17
  31. package/lib/scoring.js +22 -13
  32. package/lib/sign.js +5 -5
  33. package/lib/validate-catalog-meta.js +1 -1
  34. package/lib/validate-cve-catalog.js +2 -2
  35. package/lib/validate-indexes.js +2 -2
  36. package/lib/verify.js +63 -13
  37. package/manifest.json +47 -47
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +6 -6
  40. package/scripts/check-manifest-snapshot.js +1 -1
  41. package/scripts/check-sbom-currency.js +1 -1
  42. package/scripts/predeploy.js +6 -6
  43. package/scripts/refresh-manifest-snapshot.js +2 -2
  44. package/scripts/validate-vendor-online.js +1 -1
  45. package/scripts/verify-shipped-tarball.js +15 -12
  46. package/skills/compliance-theater/skill.md +4 -1
  47. package/skills/exploit-scoring/skill.md +20 -1
  48. package/skills/framework-gap-analysis/skill.md +6 -2
  49. package/skills/kernel-lpe-triage/skill.md +50 -3
  50. package/skills/threat-model-currency/skill.md +7 -5
  51. package/skills/webapp-security/skill.md +1 -1
  52. package/skills/zeroday-gap-learn/skill.md +44 -1
package/bin/exceptd.js CHANGED
@@ -59,7 +59,7 @@ const { spawnSync } = require("child_process");
59
59
  const PKG_ROOT = path.resolve(__dirname, "..");
60
60
 
61
61
  /**
62
- * Audit Q P1 + R F6: factor the EXPECTED_FINGERPRINT pin check used by
62
+ * Factor the EXPECTED_FINGERPRINT pin check used by
63
63
  * the attestation pipeline. Centralizes the policy (compute live SHA-256
64
64
  * fingerprint of the loaded public.pem, compare to keys/EXPECTED_FINGERPRINT,
65
65
  * honor KEYS_ROTATED=1 bypass, tolerate missing pin file) so every site
@@ -85,8 +85,12 @@ 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
+ // KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
89
+ // pin file (Notepad with files.encoding=utf8bom) is tolerated identically
90
+ // across every verify site. The helper strips leading U+FEFF + ignores
91
+ // comment 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
95
  if (process.env.KEYS_ROTATED === "1") return null;
92
96
  return (
@@ -607,7 +611,7 @@ function emit(obj, pretty, humanRenderer) {
607
611
  }
608
612
 
609
613
  function emitError(msg, extra, pretty) {
610
- // v0.12.14 (audit A P1-2): the v0.11.13 emit() fix used exitCode + return
614
+ // v0.12.14: the v0.11.13 emit() fix used exitCode + return
611
615
  // to defend stdout-buffered writes from truncation under piped consumers.
612
616
  // emitError() (stderr) kept process.exit(1), which has the same truncation
613
617
  // class — CLAUDE.md's "fix the class, not the instance." Now: write to
@@ -619,6 +623,64 @@ function emitError(msg, extra, pretty) {
619
623
  process.exitCode = 1;
620
624
  }
621
625
 
626
+ /**
627
+ * EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
628
+ * UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
629
+ * `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
630
+ * becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
631
+ * UTF-16 as garbage. Route every operator-supplied JSON file through here.
632
+ *
633
+ * 1. read as Buffer
634
+ * 2. detect BOM (UTF-16 LE / BE / UTF-8 BOM)
635
+ * 3. decode appropriately, strip leading BOM if present
636
+ * 4. JSON.parse
637
+ *
638
+ * On parse failure, throw a clean message that preserves the operator-facing
639
+ * path but does NOT leak the raw V8 parser stack — operators see "failed to
640
+ * parse JSON at <path>: <reason>", not a 12-line trace.
641
+ */
642
+ function readJsonFile(filePath) {
643
+ let buf;
644
+ try { buf = fs.readFileSync(filePath); }
645
+ catch (e) { throw new Error(`failed to read ${filePath}: ${e.message}`); }
646
+ let text;
647
+ if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) {
648
+ text = buf.slice(2).toString("utf16le");
649
+ } else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
650
+ // UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
651
+ //
652
+ // refuse odd-length payloads up front rather than carry
653
+ // the trailing byte through a partial swap. A UTF-16BE payload by
654
+ // definition has an even byte count after the BOM; odd-length input is
655
+ // either truncated or not UTF-16BE at all.
656
+ //
657
+ // use Buffer.alloc (zero-initialised) instead of
658
+ // Buffer.allocUnsafe so an unexpected loop bound never lets uninitialised
659
+ // heap bytes leak into the decoded string and downstream JSON.parse
660
+ // error message.
661
+ const payloadLength = buf.length - 2;
662
+ if (payloadLength % 2 !== 0) {
663
+ 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.`);
664
+ }
665
+ const swapped = Buffer.alloc(payloadLength);
666
+ for (let i = 2; i < buf.length - 1; i += 2) {
667
+ swapped[i - 2] = buf[i + 1];
668
+ swapped[i - 1] = buf[i];
669
+ }
670
+ text = swapped.toString("utf16le");
671
+ } else if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
672
+ text = buf.slice(3).toString("utf8");
673
+ } else {
674
+ text = buf.toString("utf8");
675
+ }
676
+ // Belt-and-braces: strip any residual leading U+FEFF the decode may have left.
677
+ if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
678
+ try { return JSON.parse(text); }
679
+ catch (e) {
680
+ throw new Error(`failed to parse JSON at ${filePath}: ${e.message}`);
681
+ }
682
+ }
683
+
622
684
  function readEvidence(evidenceFlag) {
623
685
  if (!evidenceFlag) return {};
624
686
  if (evidenceFlag === "-") {
@@ -637,13 +699,84 @@ function readEvidence(evidenceFlag) {
637
699
  if (stat.size > MAX_EVIDENCE_BYTES) {
638
700
  throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
639
701
  }
640
- return JSON.parse(fs.readFileSync(evidenceFlag, "utf8"));
702
+ // EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
703
+ // Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
704
+ // decode in readFileSync chokes on the leading 0xFEFF.
705
+ return readJsonFile(evidenceFlag);
641
706
  }
642
707
 
643
708
  function loadRunner() {
644
709
  return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
645
710
  }
646
711
 
712
+ /**
713
+ * EE P1-7: detect whether stdin actually has data without blocking.
714
+ *
715
+ * `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
716
+ * `false`, `undefined`, OR `null`. Test harnesses with custom stdin
717
+ * duplexers (Mocha/Jest, some Docker stdin-passthrough wrappers) leave
718
+ * isTTY === undefined but never write any bytes — falling into
719
+ * `fs.readFileSync(0, "utf8")` then BLOCKS waiting for an EOF that
720
+ * never arrives.
721
+ *
722
+ * Strategy:
723
+ *
724
+ * 1. If isTTY is truthy → operator is at a terminal, never read stdin.
725
+ * 2. POSIX: trust isFIFO / isSocket / isCharacterDevice. Regular file
726
+ * requires size > 0 (empty file redirection should not be treated
727
+ * as piped input).
728
+ * 3. Windows: `isTTY === false` strict (filters out wrapped test
729
+ * duplexers which leave isTTY === undefined). DO NOT gate on size
730
+ * because Windows pipes report as regular files with size 0 even
731
+ * when bytes are queued — gating would silently skip every
732
+ * `echo {...} | exceptd run` invocation.
733
+ * 4. If a wrapped test harness on Windows does want stdin auto-read
734
+ * to skip, the harness must set `process.stdin.isTTY = undefined`
735
+ * explicitly (Mocha/Jest do this by default).
736
+ *
737
+ * Returns `true` if the caller may safely fs.readFileSync(0) without
738
+ * risking an indefinite block on a wrapped empty stream.
739
+ */
740
+ function hasReadableStdin() {
741
+ if (process.stdin.isTTY) return false;
742
+ let st;
743
+ try { st = fs.fstatSync(0); }
744
+ catch {
745
+ // KK P1-4: fstat failed — tighten the Windows fallback to require
746
+ // `isTTY === false` STRICTLY (not falsy). Pre-fix `!process.stdin.isTTY`
747
+ // returned true when isTTY was undefined (Mocha/Jest test harness with
748
+ // wrapped duplexer on Windows), so the caller called `fs.readFileSync(0)`
749
+ // and blocked indefinitely waiting on an EOF that never came. The legacy
750
+ // MSYS-bash piping scenario (R-F3 in v0.12.16) sets isTTY === false on
751
+ // win32 when piped, so the strict check preserves that working case.
752
+ if (process.platform === "win32") return process.stdin.isTTY === false;
753
+ return false;
754
+ }
755
+ // POSIX pipes / FIFOs / sockets / character devices report size 0
756
+ // even when bytes are queued (or about to be). Trust them — a real
757
+ // `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
758
+ // will read to EOF cleanly. If the write end is open and no bytes
759
+ // arrive, the read blocks — that's the operator's contract, not the
760
+ // CLI's to second-guess. Wrapped test harnesses that never write
761
+ // should pass `--evidence -` explicitly.
762
+ if (typeof st.isFIFO === "function" && st.isFIFO()) return true;
763
+ if (typeof st.isSocket === "function" && st.isSocket()) return true;
764
+ if (typeof st.isCharacterDevice === "function" && st.isCharacterDevice()) return true;
765
+ // Regular file (e.g. `exceptd run <evidence.json` shell redirect).
766
+ // size 0 here means a legitimately empty file.
767
+ if (typeof st.size === "number" && st.size > 0) return true;
768
+ // Windows fallback: pipes don't surface as FIFOs via fstat on win32
769
+ // (they appear as regular files with size 0 even when bytes queued).
770
+ // Trust isTTY === false strictly — that filters out wrapped test
771
+ // duplexers (which leave isTTY === undefined) while keeping cmd.exe /
772
+ // PowerShell / MSYS pipes working (isTTY === false when piped). Do NOT
773
+ // gate on size > 0 here: a Windows pipe with bytes queued reports as
774
+ // a regular file with size 0, and gating would silently skip every
775
+ // `echo {...} | exceptd run|ingest|ai-run` invocation.
776
+ if (process.platform === "win32" && process.stdin.isTTY === false) return true;
777
+ return false;
778
+ }
779
+
647
780
  /**
648
781
  * R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
649
782
  * `attest list --since` and `reattest --since` accepted anything Date.parse
@@ -717,8 +850,17 @@ function detectVexShape(doc) {
717
850
  if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
718
851
  return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
719
852
  }
720
- if (doc.bomFormat === "CycloneDX" && !Array.isArray(doc.vulnerabilities)) {
721
- return { ok: false, detected: "cyclonedx-sbom-without-vulnerabilities", top_level_keys: keys };
853
+ // EE P1-1: a CycloneDX SBOM with no `vulnerabilities` key is a legitimate
854
+ // "0-CVE VEX filter" submission the operator is asserting nothing here is
855
+ // exploitable. Accept it as cyclonedx-vex with an empty filter set (the
856
+ // runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
857
+ // for documents that carry a CycloneDX-flavored specVersion ("1.x") without
858
+ // bomFormat — Windows tooling sometimes drops the marker on export.
859
+ const cyclonedxMarker =
860
+ doc.bomFormat === "CycloneDX" ||
861
+ (typeof doc.specVersion === "string" && /^1\./.test(doc.specVersion));
862
+ if (cyclonedxMarker && !Array.isArray(doc.vulnerabilities)) {
863
+ return { ok: true, detected: "cyclonedx-vex-zero-cve", top_level_keys: keys };
722
864
  }
723
865
  if (Array.isArray(doc.statements) && !ctxStr) {
724
866
  return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
@@ -863,13 +1005,164 @@ function dispatchPlaybook(cmd, argv) {
863
1005
  pretty
864
1006
  );
865
1007
  }
866
- runOpts.operator = args.operator;
1008
+ // EE P1-3: the ASCII-only control-char regex above misses Unicode
1009
+ // categories Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
1010
+ // zero-width joiners (U+200B-D), invisible format chars, private-use
1011
+ // codepoints, unassigned codepoints. An operator string like
1012
+ // "alice‮evilbob" renders as "alicebobevila" in any UI that respects
1013
+ // bidi — a forgery surface where the attested name looks like Bob but the
1014
+ // bytes are Alice. Reject anything outside a positive allowlist of
1015
+ // printable ASCII + most BMP printable codepoints (skipping the format /
1016
+ // control / surrogate gaps).
1017
+ //
1018
+ // Implementation: NFC-normalise first (so a decomposed sequence can't
1019
+ // smuggle a combining mark past the codepoint check), then iterate
1020
+ // codepoints and refuse Cc/Cf/Co/Cn. We use \p{C} via the `u` regex flag,
1021
+ // which matches Cc + Cf + Cs + Co + Cn in one shot. Unicode 15.1 is the
1022
+ // baseline supported by Node 20.
1023
+ let normalized;
1024
+ try { normalized = args.operator.normalize("NFC"); }
1025
+ catch (e) {
1026
+ return emitError(
1027
+ `run: --operator failed Unicode NFC normalisation: ${e.message}`,
1028
+ { provided_length: args.operator.length },
1029
+ pretty
1030
+ );
1031
+ }
1032
+ if (normalized.length === 0) {
1033
+ return emitError(
1034
+ "run: --operator is empty after Unicode NFC normalisation. Pass a meaningful identifier or omit the flag.",
1035
+ null,
1036
+ pretty
1037
+ );
1038
+ }
1039
+ if (/\p{C}/u.test(normalized)) {
1040
+ // Find the offending codepoint to surface a useful hint without
1041
+ // round-tripping the raw bytes into the error body.
1042
+ let offending = "";
1043
+ for (const cp of normalized) {
1044
+ if (/\p{C}/u.test(cp)) {
1045
+ offending = "U+" + cp.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
1046
+ break;
1047
+ }
1048
+ }
1049
+ return emitError(
1050
+ `run: --operator contains a Unicode control / format / private-use / unassigned codepoint (${offending}). Bidi overrides (U+202E), zero-width joiners (U+200B–D), and format marks corrupt attestation rendering and enable name-forgery. Use printable identifiers only.`,
1051
+ { provided_length: args.operator.length, offending_codepoint: offending },
1052
+ pretty
1053
+ );
1054
+ }
1055
+ runOpts.operator = normalized;
1056
+ }
1057
+
1058
+ // NN P1-1 / P1-2 / P1-5: --csaf-status and --publisher-namespace shape the
1059
+ // CSAF bundle emitted by phases 5-7. Verbs that don't drive those phases
1060
+ // (brief, plan, govern, direct, look, attest, list-attestations, discover,
1061
+ // doctor, lint, ask, verify-attestation, reattest) never assemble a
1062
+ // bundle, so silently consuming these flags is a UX trap. Refuse on those
1063
+ // verbs so the operator knows the flag was discarded — same pattern as
1064
+ // EE P1-6 closed for --ack. Error message templates and emitError prefixes
1065
+ // use the in-scope `cmd` verb so a brief invocation says "brief:" rather
1066
+ // than misattributing the flag to run.
1067
+ const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
1068
+ "run", "ci", "run-all", "ai-run", "ingest",
1069
+ ]);
1070
+
1071
+ // --publisher-namespace <url> threads into the CSAF
1072
+ // bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
1073
+ // namespace to be the publisher's trust anchor — i.e. the OPERATOR
1074
+ // running the scan, not the tooling vendor. Pre-fix this was hard-coded
1075
+ // to https://exceptd.com, misattributing responsibility for advisory
1076
+ // accuracy. Validation mirrors --operator (string, ≤256 chars, no
1077
+ // ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
1078
+ if (args["publisher-namespace"] !== undefined) {
1079
+ if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
1080
+ return emitError(
1081
+ `${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.`,
1082
+ { verb: cmd, flag: "publisher-namespace", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
1083
+ pretty
1084
+ );
1085
+ }
1086
+ const ns = args["publisher-namespace"];
1087
+ if (typeof ns !== "string") {
1088
+ return emitError(`${cmd}: --publisher-namespace must be a string.`, { provided: typeof ns }, pretty);
1089
+ }
1090
+ // eslint-disable-next-line no-control-regex
1091
+ if (/[\x00-\x1F\x7F]/.test(ns)) {
1092
+ return emitError(
1093
+ `${cmd}: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.`,
1094
+ { provided_length: ns.length },
1095
+ pretty
1096
+ );
1097
+ }
1098
+ if (ns.length === 0 || ns.length > 256) {
1099
+ return emitError(
1100
+ `${cmd}: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
1101
+ { provided_length: ns.length },
1102
+ pretty
1103
+ );
1104
+ }
1105
+ if (!/^https?:\/\//i.test(ns)) {
1106
+ return emitError(
1107
+ `${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.`,
1108
+ { provided: ns.slice(0, 80) },
1109
+ pretty
1110
+ );
1111
+ }
1112
+ runOpts.publisherNamespace = ns;
1113
+ }
1114
+
1115
+ // --csaf-status promotes the CSAF tracking.status from the
1116
+ // runtime default (`interim`) to `final` for operators who have reviewed
1117
+ // the advisory and accept the immutable-advisory contract of CSAF
1118
+ // §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
1119
+ // rejected at input so an operator typo (`finel`) doesn't silently fall
1120
+ // back to interim and produce surprise.
1121
+ if (args["csaf-status"] !== undefined) {
1122
+ if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
1123
+ return emitError(
1124
+ `${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.`,
1125
+ { verb: cmd, flag: "csaf-status", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
1126
+ pretty
1127
+ );
1128
+ }
1129
+ const cs = args["csaf-status"];
1130
+ const allowed = ["draft", "interim", "final"];
1131
+ if (typeof cs !== "string" || !allowed.includes(cs)) {
1132
+ return emitError(
1133
+ `${cmd}: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
1134
+ { provided: cs },
1135
+ pretty
1136
+ );
1137
+ }
1138
+ runOpts.csafStatus = cs;
867
1139
  }
1140
+
868
1141
  // --ack: operator acknowledges the jurisdiction obligations surfaced by
869
1142
  // govern. Captured in attestation; downstream tooling can check whether
870
1143
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
871
1144
  // and wait for ack — this is how the ack gets recorded.
872
- if (args.ack) runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
1145
+ //
1146
+ // EE P1-6: --ack only makes sense on verbs that drive phases 5-7 (run /
1147
+ // ingest / ai-run / ci / run-all / reattest). Info-only verbs (brief,
1148
+ // plan, govern, direct, look, attest, list-attestations, discover,
1149
+ // doctor, lint, ask, verify-attestation) never consume an attestation
1150
+ // clock — accepting --ack silently here was a UX trap where operators
1151
+ // believed they had recorded consent. Refuse on those verbs so the
1152
+ // operator knows the flag is irrelevant.
1153
+ const ACK_RELEVANT_VERBS = new Set([
1154
+ "run", "ingest", "ai-run", "ci", "run-all", "reattest",
1155
+ ]);
1156
+ if (args.ack) {
1157
+ if (!ACK_RELEVANT_VERBS.has(cmd)) {
1158
+ return emitError(
1159
+ `${cmd}: --ack is irrelevant on this verb (no jurisdiction clock at stake). --ack only applies to verbs that drive phases 5-7: ${[...ACK_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --ack, or use \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --ack\` once you're past the briefing step.`,
1160
+ { verb: cmd, accepted_verbs: [...ACK_RELEVANT_VERBS].sort() },
1161
+ pretty
1162
+ );
1163
+ }
1164
+ runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
1165
+ }
873
1166
 
874
1167
  let runner;
875
1168
  try {
@@ -1074,6 +1367,15 @@ Flags:
1074
1367
  --ack Mark explicit operator consent to the jurisdiction
1075
1368
  obligations surfaced by govern. Persisted under
1076
1369
  attestation.operator_consent.
1370
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1371
+ bundle. One of: draft | interim (default) | final.
1372
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1373
+ set this only after operator review of the advisory.
1374
+ --publisher-namespace <url>
1375
+ CSAF document.publisher.namespace (§3.1.7.4). The
1376
+ publisher trust anchor — i.e. the operator's
1377
+ organisation, NOT the tooling vendor. Must be an
1378
+ http://… or https://… URL, ≤256 chars.
1077
1379
  --diff-from-latest Compare evidence_hash against the most recent prior
1078
1380
  attestation for the same playbook in
1079
1381
  .exceptd/attestations/. Emits status: unchanged | drifted.
@@ -1102,7 +1404,21 @@ Flags:
1102
1404
  --pretty Indented JSON output.
1103
1405
 
1104
1406
  Attestation is persisted to .exceptd/attestations/<session_id>/ on every
1105
- successful run (single: attestation.json; multi: <playbook_id>.json).`,
1407
+ successful run (single: attestation.json; multi: <playbook_id>.json).
1408
+
1409
+ Exit codes (per-verb, post-run):
1410
+ 0 PASS Run completed; classification clean, RWEP under cap.
1411
+ 1 Framework error Runner threw, unreadable evidence, etc.
1412
+ 2 FAIL (detected) classification=detected OR rwep ≥ escalate cap.
1413
+ 3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
1414
+ 4 Blocked Result returned ok:false (preflight halt).
1415
+ 5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
1416
+ 8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
1417
+ attestation lock after the bounded retry budget
1418
+ (~1-2s). Distinct from 1 so callers can retry the
1419
+ operation rather than treat it as a hard failure.
1420
+ Surfaces as body.lock_contention=true,
1421
+ body.exit_code=8.`,
1106
1422
  ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
1107
1423
 
1108
1424
  Flags:
@@ -1209,7 +1525,7 @@ Flags:
1209
1525
  Stdin event grammar (one JSON object per line):
1210
1526
  {"event":"evidence","payload":{"observations":{},"verdict":{}}}
1211
1527
 
1212
- Stdin acceptance contract (Audit L F22):
1528
+ Stdin acceptance contract:
1213
1529
  In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
1214
1530
  parseable {"event":"evidence","payload":{...}} line. That line wins:
1215
1531
  subsequent evidence events on the same run are ignored (the handler
@@ -1262,6 +1578,11 @@ Flags:
1262
1578
  summary (5-field digest), markdown (human digest).
1263
1579
  Bundles (csaf-2.0/sarif/openvex) live on per-run
1264
1580
  attestations, not the aggregate ci verdict.
1581
+ --csaf-status <s> CSAF tracking.status threaded into per-run bundles.
1582
+ One of: draft | interim (default) | final.
1583
+ --publisher-namespace <url>
1584
+ CSAF document.publisher.namespace (§3.1.7.4). The
1585
+ operator's organisation URL, NOT the tooling vendor.
1265
1586
  --json Force single-line JSON (overrides any TTY heuristics).
1266
1587
  --pretty Indented JSON output (implies --json).
1267
1588
 
@@ -1643,7 +1964,7 @@ function cmdPlan(runner, args, runOpts, pretty) {
1643
1964
  emit(plan, pretty);
1644
1965
  }
1645
1966
 
1646
- // v0.12.15 (audit L F1, F2): --scope must validate against the accepted
1967
+ // v0.12.15: --scope must validate against the accepted
1647
1968
  // set. The prior shape silently returned [] for any unknown scope, which
1648
1969
  // in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
1649
1970
  // "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
@@ -1823,7 +2144,15 @@ function cmdRun(runner, args, runOpts, pretty) {
1823
2144
  // fired, making `echo '{...}' | exceptd run <pb>` silently behave like
1824
2145
  // no-evidence on Windows. cmdAiRun's path (below) already uses the
1825
2146
  // truthy form, so this brings cmdRun + cmdIngest to parity.
1826
- if (!args.evidence && !process.stdin.isTTY) {
2147
+ //
2148
+ // EE P1-7: use the fstat-probing hasReadableStdin() helper instead of
2149
+ // the raw `!process.stdin.isTTY` truthy check. Test harnesses with
2150
+ // wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
2151
+ // isTTY === undefined but have no data — the raw check fell into
2152
+ // readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
2153
+ // hasReadableStdin() does an fstat() probe first, then falls back to
2154
+ // the truthy check only on Windows (where fstat on a pipe is unreliable).
2155
+ if (!args.evidence && hasReadableStdin()) {
1827
2156
  args.evidence = "-";
1828
2157
  }
1829
2158
  if (args.evidence) {
@@ -1859,10 +2188,11 @@ function cmdRun(runner, args, runOpts, pretty) {
1859
2188
  if (args.vex) {
1860
2189
  let vexDoc;
1861
2190
  // R-F5: cap --vex file size the same way readEvidence() caps --evidence
1862
- // (32 MB). Pre-fix, --vex did a raw readFileSync with no size check —
1863
- // an operator passing a multi-GB file (binary log, JSON bomb, or
1864
- // accident) blocked the event loop for minutes / OOM'd the process.
1865
- // 32 MB is well beyond any legitimate VEX submission.
2191
+ // (32 MiB — binary mebibytes, i.e. 32 * 1024 * 1024 = 33,554,432 bytes).
2192
+ // Pre-fix, --vex did a raw readFileSync with no size check — an operator
2193
+ // passing a multi-GB file (binary log, JSON bomb, or accident) blocked
2194
+ // the event loop for minutes / OOM'd the process. 32 MiB is well beyond
2195
+ // any legitimate VEX submission.
1866
2196
  const MAX_VEX_BYTES = 32 * 1024 * 1024;
1867
2197
  let vstat;
1868
2198
  try { vstat = fs.statSync(args.vex); }
@@ -1870,14 +2200,19 @@ function cmdRun(runner, args, runOpts, pretty) {
1870
2200
  return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
1871
2201
  }
1872
2202
  if (vstat.size > MAX_VEX_BYTES) {
2203
+ // EE P1-4: error message names the binary mebi convention explicitly so
2204
+ // operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
1873
2205
  return emitError(
1874
- `run: --vex file too large: ${vstat.size} bytes > ${MAX_VEX_BYTES} byte limit. Reduce the document or split into multiple passes.`,
2206
+ `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.`,
1875
2207
  { provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
1876
2208
  pretty
1877
2209
  );
1878
2210
  }
1879
2211
  try {
1880
- vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
2212
+ // EE P1-2: BOM-tolerant read. Windows-tool-emitted CycloneDX commonly
2213
+ // carries UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
2214
+ // readFileSync chokes on the leading 0xFEFF.
2215
+ vexDoc = readJsonFile(args.vex);
1881
2216
  } catch (e) {
1882
2217
  return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
1883
2218
  }
@@ -1899,6 +2234,16 @@ function cmdRun(runner, args, runOpts, pretty) {
1899
2234
  const vexSet = runner.vexFilterFromDoc(vexDoc);
1900
2235
  submission.signals = submission.signals || {};
1901
2236
  submission.signals.vex_filter = [...vexSet];
2237
+ // BB P1-3: vexFilterFromDoc attaches a `.fixed` Set as an own property
2238
+ // on the returned filter Set (CycloneDX `analysis.state: 'resolved'`
2239
+ // + OpenVEX `status: 'fixed'` go into this set). Without forwarding it
2240
+ // through to signals.vex_fixed, analyze() never receives the fixed-
2241
+ // disposition CVE ids — `vexFixed` stays null, `vex_status: 'fixed'`
2242
+ // never gets annotated onto matched_cves entries, and CSAF
2243
+ // product_status.fixed + OpenVEX status:'fixed' are unreachable from
2244
+ // the CLI. The bundle-correctness tests only exercised the analyze()
2245
+ // direct-call path with vex_fixed pre-injected, hiding this regression.
2246
+ submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
1902
2247
  } catch (e) {
1903
2248
  return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
1904
2249
  }
@@ -1935,9 +2280,27 @@ function cmdRun(runner, args, runOpts, pretty) {
1935
2280
  // v0.11.10 (#119): add result.ack alias for consumers reading the
1936
2281
  // ack state by that name (`result.ack` is shorter + matches the CLI flag).
1937
2282
  if (result && runOpts.operator) result.operator = runOpts.operator;
2283
+
2284
+ // EE P1-6: --ack consent only counts when a jurisdiction clock is actually
2285
+ // at stake — i.e. the run produced classification=detected (a real finding
2286
+ // that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
2287
+ // not-detected or inconclusive run, persisting the consent silently was
2288
+ // misleading: the attestation file recorded operator acknowledgement of
2289
+ // a clock that never started. Now: surface the ack state in the run body
2290
+ // either way so operators see what happened, but only persist
2291
+ // `operator_consent` into the attestation when classification === detected.
2292
+ const detectClassification = result && result.phases && result.phases.detect
2293
+ ? result.phases.detect.classification
2294
+ : null;
2295
+ const consentApplies =
2296
+ !!runOpts.operator_consent && detectClassification === "detected";
1938
2297
  if (result && runOpts.operator_consent) {
1939
2298
  result.operator_consent = runOpts.operator_consent;
1940
2299
  result.ack = !!runOpts.operator_consent.explicit;
2300
+ result.ack_applied = consentApplies;
2301
+ if (!consentApplies) {
2302
+ result.ack_skipped_reason = `classification=${detectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2303
+ }
1941
2304
  } else if (result) {
1942
2305
  result.ack = false;
1943
2306
  }
@@ -1950,17 +2313,25 @@ function cmdRun(runner, args, runOpts, pretty) {
1950
2313
  directiveId: result.directive_id,
1951
2314
  evidenceHash: result.evidence_hash,
1952
2315
  operator: runOpts.operator,
1953
- operatorConsent: runOpts.operator_consent,
2316
+ // EE P1-6: gate consent persistence on classification=detected.
2317
+ operatorConsent: consentApplies ? runOpts.operator_consent : null,
1954
2318
  submission,
1955
2319
  runOpts,
1956
2320
  forceOverwrite: !!args["force-overwrite"],
1957
2321
  filename: "attestation.json",
1958
2322
  });
1959
2323
  if (!persistResult.ok) {
1960
- // Session-id collision without --force-overwrite. Refuse, surface the
1961
- // existing path so the operator can decide, and emit JSON to stderr
1962
- // matching the unified error shape. Exit non-zero a silent overwrite
1963
- // is a tamper-evidence violation.
2324
+ // Session-id collision without --force-overwrite, OR --force-overwrite
2325
+ // lost the lockfile race. Refuse, surface the existing path so the
2326
+ // operator can decide, emit JSON to stderr matching the unified error
2327
+ // shape. Exit non-zero — a silent overwrite is a tamper-evidence
2328
+ // violation. v0.12.14: exitCode + return instead of process.exit so
2329
+ // the stderr line drains under piped CI consumers.
2330
+ //
2331
+ // When persistAttestation lost the lockfile race it pinned
2332
+ // process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
2333
+ // overwrite that with 3 — preserve the exit-8 contract callers depend
2334
+ // on to distinguish lock-busy from collision.
1964
2335
  const err = {
1965
2336
  ok: false,
1966
2337
  error: persistResult.error,
@@ -1968,10 +2339,14 @@ function cmdRun(runner, args, runOpts, pretty) {
1968
2339
  hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
1969
2340
  verb: "run",
1970
2341
  };
1971
- // v0.12.14 (audit A P1-2): exitCode + return instead of process.exit
1972
- // so the stderr line drains under piped CI consumers.
2342
+ if (persistResult.lock_contention) {
2343
+ err.lock_contention = true;
2344
+ err.exit_code = 8;
2345
+ }
1973
2346
  process.stderr.write(JSON.stringify(err) + "\n");
1974
- process.exitCode = 3;
2347
+ if (!persistResult.lock_contention) {
2348
+ process.exitCode = 3;
2349
+ }
1975
2350
  return;
1976
2351
  }
1977
2352
  if (persistResult.prior_session_id) {
@@ -2390,6 +2765,34 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2390
2765
  if (!lst.isFile()) {
2391
2766
  return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2392
2767
  }
2768
+ // EE P1-5: Windows directory junctions are reparse-point dirs that
2769
+ // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
2770
+ // ordinary directories). They bypass the symlink refusal above. Use
2771
+ // realpathSync to resolve the entry and confirm it still lives under
2772
+ // the resolved evidence-dir — the realpath approach is portable
2773
+ // (catches POSIX symlinks too, defense in depth) and works regardless
2774
+ // of whether the OS exposes reparse-point bits.
2775
+ let realEntry;
2776
+ try { realEntry = fs.realpathSync(entryPath); }
2777
+ catch (e) {
2778
+ return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
2779
+ }
2780
+ if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
2781
+ return emitError(
2782
+ `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
2783
+ { entry: f, resolved_to: realEntry },
2784
+ pretty
2785
+ );
2786
+ }
2787
+ // EE P1-5 (hardlink defense in depth): no clean cross-platform refusal
2788
+ // exists — hardlinks are indistinguishable from regular files at the
2789
+ // inode level. Surface a stderr warning when nlink > 1 so the operator
2790
+ // is aware a second name may point at the same file. Not a refusal —
2791
+ // legitimate use cases (atomic rename, package-manager dedup) produce
2792
+ // nlink > 1 without malicious intent.
2793
+ if (lst.nlink > 1) {
2794
+ process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
2795
+ }
2393
2796
  try {
2394
2797
  bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
2395
2798
  } catch (e) {
@@ -2412,6 +2815,32 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2412
2815
 
2413
2816
  const result = runner.run(id, directiveId, submission, perRunOpts);
2414
2817
 
2818
+ // NN P1-4: mirror the cmdRun consent gate (EE P1-6). --ack consent only
2819
+ // counts when a jurisdiction clock is actually at stake on THIS
2820
+ // playbook's verdict — i.e. its detect.classification === 'detected'.
2821
+ // Pre-fix cmdRunMulti passed `perRunOpts.operator_consent` for every
2822
+ // playbook in the iteration regardless of that playbook's individual
2823
+ // classification, so a single --ack on a run-all invocation persisted
2824
+ // explicit consent into attestations whose run never started a clock.
2825
+ // Now: per-playbook gating with the same `ack_skipped_reason` surface
2826
+ // cmdRun emits, so consumers see exactly which playbooks consumed the
2827
+ // ack and which didn't.
2828
+ const perDetectClassification = result && result.phases && result.phases.detect
2829
+ ? result.phases.detect.classification
2830
+ : null;
2831
+ const perConsentApplies =
2832
+ !!perRunOpts.operator_consent && perDetectClassification === "detected";
2833
+ if (result && perRunOpts.operator_consent) {
2834
+ result.operator_consent = perRunOpts.operator_consent;
2835
+ result.ack = !!perRunOpts.operator_consent.explicit;
2836
+ result.ack_applied = perConsentApplies;
2837
+ if (!perConsentApplies) {
2838
+ result.ack_skipped_reason = `classification=${perDetectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2839
+ }
2840
+ } else if (result) {
2841
+ result.ack = false;
2842
+ }
2843
+
2415
2844
  // Persist per-playbook attestation under the shared session.
2416
2845
  if (result && result.ok) {
2417
2846
  const persisted = persistAttestation({
@@ -2420,7 +2849,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2420
2849
  directiveId,
2421
2850
  evidenceHash: result.evidence_hash,
2422
2851
  operator: perRunOpts.operator,
2423
- operatorConsent: perRunOpts.operator_consent,
2852
+ // NN P1-4: gate consent persistence on this playbook's
2853
+ // classification, not on the aggregate run's --ack presence.
2854
+ operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
2424
2855
  submission,
2425
2856
  runOpts: perRunOpts,
2426
2857
  forceOverwrite: !!args["force-overwrite"],
@@ -2486,7 +2917,11 @@ function cmdIngest(runner, args, runOpts, pretty) {
2486
2917
  // strict `=== false` check failed and ingest silently treated the
2487
2918
  // routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
2488
2919
  // detects piped stdin") was a no-op on Windows pre-fix.
2489
- if (!args.evidence && !process.stdin.isTTY) {
2920
+ //
2921
+ // EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
2922
+ // Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
2923
+ // would otherwise block here forever on the readFileSync(0) call.
2924
+ if (!args.evidence && hasReadableStdin()) {
2490
2925
  args.evidence = "-";
2491
2926
  }
2492
2927
  if (args.evidence) {
@@ -2524,21 +2959,42 @@ function cmdIngest(runner, args, runOpts, pretty) {
2524
2959
  // calls with the same session-id silently clobbered the audit trail and no
2525
2960
  // .sig sidecar was written.
2526
2961
  if (result && result.ok && result.session_id) {
2962
+ // Mirror cmdRun / cmdRunMulti: gate operator_consent persistence on
2963
+ // classification === 'detected'. --ack is meaningful only when a
2964
+ // jurisdiction clock is at stake; persisting consent on a
2965
+ // not-detected ingest forges audit-trail consent for a clock that
2966
+ // never started.
2967
+ const ingestClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
2968
+ const ingestConsentApplies = ingestClassification === "detected";
2969
+ if (runOpts.operator_consent && !ingestConsentApplies) {
2970
+ result.ack = true;
2971
+ result.ack_applied = false;
2972
+ result.ack_skipped_reason = `classification=${ingestClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2973
+ }
2527
2974
  const persisted = persistAttestation({
2528
2975
  sessionId: result.session_id,
2529
2976
  playbookId: result.playbook_id,
2530
2977
  directiveId: result.directive_id,
2531
2978
  evidenceHash: result.evidence_hash,
2532
2979
  operator: runOpts.operator,
2533
- operatorConsent: runOpts.operator_consent,
2980
+ operatorConsent: ingestConsentApplies ? runOpts.operator_consent : null,
2534
2981
  submission: cleanedSubmission,
2535
2982
  runOpts,
2536
2983
  forceOverwrite: !!args["force-overwrite"],
2537
2984
  filename: "attestation.json",
2538
2985
  });
2539
2986
  if (!persisted.ok) {
2540
- // Surface the collision; do not silently clobber.
2541
- return emitError(persisted.error, { session_id: result.session_id, existing_path: persisted.existingPath }, pretty);
2987
+ // Surface the collision; do not silently clobber. Preserve
2988
+ // LOCK_CONTENTION exit 8 set by persistAttestation when
2989
+ // --force-overwrite hit the lockfile race.
2990
+ const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
2991
+ if (persisted.lock_contention) {
2992
+ ctx.lock_contention = true;
2993
+ ctx.exit_code = 8;
2994
+ process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
2995
+ return;
2996
+ }
2997
+ return emitError(persisted.error, ctx, pretty);
2542
2998
  }
2543
2999
  if (persisted.prior_session_id) {
2544
3000
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
@@ -2691,8 +3147,17 @@ function persistAttestation(args) {
2691
3147
  // matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
2692
3148
  // .lock file with bounded retry, PID-liveness check on contention,
2693
3149
  // mtime fallback for orphaned lockfiles.
3150
+ // DD P1-2: MAX_RETRIES capped at 10 (was 50). persistAttestation is a
3151
+ // sync function called from sync callers throughout the CLI, so the
3152
+ // wait loop must busy-spin (no event-loop yield available). At 50
3153
+ // retries × ~200ms backoff per spin the worst case was ~10s of pegged-
3154
+ // CPU + frozen-event-loop stall under attestation contention. Capping
3155
+ // at 10 bounds the freeze at ~1-2s; beyond that callers receive the
3156
+ // LOCK_CONTENTION sentinel on the result object and can retry from the
3157
+ // outside without holding the CPU. Async refactor of persistAttestation
3158
+ // + every caller is a v0.13.0 candidate.
2694
3159
  const lockPath = filePath + ".lock";
2695
- const MAX_RETRIES = 50;
3160
+ const MAX_RETRIES = 10;
2696
3161
  const STALE_LOCK_MS = 30_000;
2697
3162
  let acquired = false;
2698
3163
  for (let i = 0; i < MAX_RETRIES; i++) {
@@ -2729,10 +3194,28 @@ function persistAttestation(args) {
2729
3194
  }
2730
3195
  }
2731
3196
  if (!acquired) {
3197
+ // DD P1-2: lock_contention sentinel so callers can distinguish a
3198
+ // genuine lock-busy condition (retry-from-outside is the right move)
3199
+ // from a hard failure (write error, permission denial). The sync
3200
+ // spin budget was bounded above so we hit this return after ~1-2s
3201
+ // of contention rather than the prior ~10s.
3202
+ //
3203
+ // PP P1-2: emit() auto-maps any ok:false body to process.exitCode = 1
3204
+ // (it only writes exitCode = 1 when the current value is 0). Pre-fix
3205
+ // the LOCK_CONTENTION return collapsed onto exit 1 along with every
3206
+ // other hard failure — defeating the "callers can distinguish
3207
+ // lock-busy from hard failure" promise. Pin process.exitCode = 8
3208
+ // HERE, before the caller hands the body to emit(); emit() will
3209
+ // preserve the already-non-zero value. Exit code 8 is reserved
3210
+ // exclusively for LOCK_CONTENTION (attestation persist); see the
3211
+ // exit-code table in printGlobalHelp().
3212
+ process.exitCode = 8;
2732
3213
  return {
2733
3214
  ok: false,
2734
- error: `Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts.`,
3215
+ 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.`,
2735
3216
  existingPath: path.relative(process.cwd(), filePath),
3217
+ lock_contention: true,
3218
+ exit_code: 8,
2736
3219
  };
2737
3220
  }
2738
3221
  try {
@@ -2770,7 +3253,7 @@ function persistAttestation(args) {
2770
3253
  * from "the .sig file was deleted by an attacker."
2771
3254
  */
2772
3255
  /**
2773
- * Audit P P1-C: byte-stability normalize() for the attestation pipeline.
3256
+ * C: byte-stability normalize() for the attestation pipeline.
2774
3257
  * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
2775
3258
  * normalize() implementations in lib/sign.js, lib/verify.js,
2776
3259
  * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
@@ -2796,7 +3279,7 @@ function maybeSignAttestation(filePath) {
2796
3279
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
2797
3280
  // verifier doesn't trust.
2798
3281
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
2799
- // Audit P P1-C: normalize attestation bytes before sign — strip leading
3282
+ // C: normalize attestation bytes before sign — strip leading
2800
3283
  // UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
2801
3284
  // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
2802
3285
  // attestation file lives on disk under .exceptd/ and can pick up CRLF
@@ -2826,20 +3309,25 @@ function maybeSignAttestation(filePath) {
2826
3309
  key: privateKey,
2827
3310
  dsaEncoding: "ieee-p1363",
2828
3311
  });
3312
+ // KK P1-1: the sidecar's Ed25519 signature covers ONLY the
3313
+ // attestation file bytes. Fields that travel inside the .sig but are
3314
+ // NOT in the signed message are replay-rewrite trivial: an attacker
3315
+ // who can write the directory can mutate them without invalidating
3316
+ // the signature. Drop `signed_at`, `signs_path`, `signs_sha256` from
3317
+ // the sidecar shape — they were unsigned metadata posing as
3318
+ // attestation context. Operators reading freshness use filesystem
3319
+ // mtime; the attestation file's `captured_at` field is what's
3320
+ // signed. The sidecar now carries only the algorithm tag, the
3321
+ // Ed25519 signature payload, and an explanatory note.
2829
3322
  fs.writeFileSync(sigPath, JSON.stringify({
2830
3323
  algorithm: "Ed25519",
2831
3324
  signature_base64: sig.toString("base64"),
2832
- signed_at: new Date().toISOString(),
2833
- signs_path: path.basename(filePath),
2834
- signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
3325
+ note: "Ed25519 signature covers the attestation file bytes only. Use filesystem mtime for freshness; use the attestation's `captured_at` for the signed timestamp.",
2835
3326
  }, null, 2));
2836
3327
  } else {
2837
3328
  fs.writeFileSync(sigPath, JSON.stringify({
2838
3329
  algorithm: "unsigned",
2839
3330
  signed: false,
2840
- signed_at: null,
2841
- signs_path: path.basename(filePath),
2842
- signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
2843
3331
  note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
2844
3332
  }, null, 2));
2845
3333
  }
@@ -2852,7 +3340,7 @@ function maybeSignAttestation(filePath) {
2852
3340
  * Returns null if neither has the session.
2853
3341
  */
2854
3342
  /**
2855
- * v0.12.14 (audit A P1-1): session-id validation — applied at every READ
3343
+ * v0.12.14: session-id validation — applied at every READ
2856
3344
  * site, not just writes. The write path (persistAttestation) was hardened
2857
3345
  * in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
2858
3346
  * accepted arbitrary strings and joined them into path.join(root, id) with
@@ -2871,7 +3359,7 @@ function validateSessionIdForRead(sessionId) {
2871
3359
  }
2872
3360
 
2873
3361
  function findSessionDir(sessionId, runOpts) {
2874
- // v0.12.14 (audit A P1-1): validate the session-id at every read path.
3362
+ // v0.12.14: validate the session-id at every read path.
2875
3363
  try { validateSessionIdForRead(sessionId); }
2876
3364
  catch { return null; }
2877
3365
  const candidates = [
@@ -2947,7 +3435,7 @@ function verifyAttestationSidecar(attFile) {
2947
3435
  const sigPath = attFile + ".sig";
2948
3436
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2949
3437
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
2950
- // Audit Q P1 + R F6: consult keys/EXPECTED_FINGERPRINT before honoring
3438
+ // Consult keys/EXPECTED_FINGERPRINT before honoring
2951
3439
  // the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
2952
3440
  // at every public-key load site," but reattest's signature verifier
2953
3441
  // loaded keys/public.pem without the pin cross-check. A coordinated
@@ -2965,17 +3453,66 @@ function verifyAttestationSidecar(attFile) {
2965
3453
  }
2966
3454
  let sigDoc;
2967
3455
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
2968
- catch (e) { return { file: attFile, signed: false, verified: false, reason: `sidecar parse error: ${e.message}` }; }
3456
+ catch (e) {
3457
+ // a corrupt-JSON sidecar is observationally indistinguishable
3458
+ // from sidecar tamper — an attacker who can rewrite attestation.json can
3459
+ // also truncate / mangle the .sig file. Surface as a distinct
3460
+ // tamper-class reason so callers can require --force-replay. Pre-fix,
3461
+ // cmdReattest only refused on `reason === "no .sig sidecar"`; a
3462
+ // parse-error reason fell through to the benign NOTE branch and replay
3463
+ // proceeded against forged input.
3464
+ return {
3465
+ file: attFile,
3466
+ signed: false,
3467
+ verified: false,
3468
+ reason: `sidecar parse error: ${e.message}`,
3469
+ tamper_class: "sidecar-corrupt",
3470
+ };
3471
+ }
2969
3472
  if (sigDoc.algorithm === "unsigned") {
3473
+ // `algorithm: "unsigned"` is only legitimate when written
3474
+ // by maybeSignAttestation() at attestation-creation time on a host
3475
+ // WITHOUT .keys/private.pem. If the verifying host HAS a private key,
3476
+ // an "unsigned" sidecar is a substitution attack: tamper attestation.json
3477
+ // (breaking Ed25519) then overwrite .sig with the unsigned stub to bypass
3478
+ // the tamper detector. Promote to tamper-class so callers can refuse.
3479
+ const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3480
+ if (fs.existsSync(privKeyPath)) {
3481
+ return {
3482
+ file: attFile,
3483
+ signed: false,
3484
+ verified: false,
3485
+ reason: "attestation explicitly unsigned but .keys/private.pem IS present on this host — sidecar substitution suspected (legitimate unsigned attestations cannot exist alongside a private key)",
3486
+ tamper_class: "unsigned-substitution",
3487
+ };
3488
+ }
2970
3489
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
2971
3490
  }
3491
+ // KK P1-3: strict algorithm check. Pre-fix the verifier branched only on
3492
+ // `=== "unsigned"`; null, undefined, "RSA-PSS", arrays, etc. fell through
3493
+ // to crypto.verify with the default Ed25519 args — which would either
3494
+ // succeed against the wrong-algorithm signature bytes accidentally (an
3495
+ // attacker who can write the sidecar can replay an existing Ed25519
3496
+ // signature under a downgrade-bait algorithm tag) or throw a generic
3497
+ // verify error. Refuse anything that isn't exactly "Ed25519" or
3498
+ // "unsigned" with a structured tamper class so callers can route the
3499
+ // refusal through the same exit-6 path as other tamper events.
3500
+ if (sigDoc.algorithm !== "Ed25519") {
3501
+ return {
3502
+ file: attFile,
3503
+ signed: false,
3504
+ verified: false,
3505
+ reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
3506
+ tamper_class: "algorithm-unsupported",
3507
+ };
3508
+ }
2972
3509
  if (!pubKey) {
2973
3510
  return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
2974
3511
  }
2975
3512
  let content;
2976
3513
  try {
2977
3514
  const raw = fs.readFileSync(attFile, "utf8");
2978
- // Audit P P1-C: apply the same normalize() used by the signer so the
3515
+ // C: apply the same normalize() used by the signer so the
2979
3516
  // verify path is byte-stable across CRLF / BOM churn (Windows checkout
2980
3517
  // with core.autocrlf=true, editor round-trips, git-attributes flips).
2981
3518
  content = normalizeAttestationBytes(raw);
@@ -3036,7 +3573,29 @@ function cmdReattest(runner, args, runOpts, pretty) {
3036
3573
  // tampering. `verified === false && signed === true` is the real tamper
3037
3574
  // signal.
3038
3575
  const verify = verifyAttestationSidecar(attFile);
3039
- if (verify.signed && !verify.verified && !args["force-replay"]) {
3576
+ // 2: collapse tamper-class detection. Any non-benign
3577
+ // sidecar state (signed-but-invalid, sidecar-corrupt, unsigned-substitution)
3578
+ // refuses replay unless --force-replay is set. The pre-fix shape only
3579
+ // refused on `verify.signed && !verify.verified` (signed-tamper) and on
3580
+ // `no .sig sidecar` (missing); corrupt-JSON sidecars and substituted
3581
+ // "unsigned" sidecars on a host WITH a private key fell into the benign
3582
+ // NOTE branch and replay proceeded against forged input.
3583
+ const isSignedTamper = verify.signed && !verify.verified;
3584
+ const isClassTamper = !verify.signed && (
3585
+ verify.tamper_class === "sidecar-corrupt"
3586
+ || verify.tamper_class === "unsigned-substitution"
3587
+ // KK P1-3: extend tamper-class refusal to algorithm-unsupported sidecars
3588
+ // (anything other than "Ed25519" or "unsigned"). Pre-fix, the verifier
3589
+ // pre-strict-check would crypto.verify against default Ed25519 args and
3590
+ // return signed:true + verified:false on failure — which DID land in
3591
+ // isSignedTamper. But a sidecar that throws inside crypto.verify (e.g.
3592
+ // signature_base64 missing on the downgrade-bait shape) was routed
3593
+ // through the catch block and emerged as signed:true + verified:false
3594
+ // by happy accident. The strict pre-check now surfaces the class
3595
+ // directly; refuse on that class too.
3596
+ || verify.tamper_class === "algorithm-unsupported"
3597
+ );
3598
+ if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
3040
3599
  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`);
3041
3600
  const body = {
3042
3601
  ok: false,
@@ -3051,10 +3610,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
3051
3610
  process.exitCode = 6;
3052
3611
  return;
3053
3612
  }
3054
- if (verify.signed && !verify.verified && args["force-replay"]) {
3613
+ if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
3055
3614
  process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding failed signature verification on ${attFile} (${verify.reason}). The replay output records sidecar_verify so the override is audit-visible.\n`);
3056
3615
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && !args["force-replay"]) {
3057
- // Audit Q P2: missing-sidecar is NOT benign. The previous flow accepted
3616
+ // missing-sidecar is NOT benign. The previous flow accepted
3058
3617
  // a missing .sig file silently (only blocked on signed-but-invalid).
3059
3618
  // Sidecar deletion is observationally identical to sidecar tamper —
3060
3619
  // an attacker who can rewrite the attestation can also rm the sidecar,
@@ -3079,6 +3638,30 @@ function cmdReattest(runner, args, runOpts, pretty) {
3079
3638
  return;
3080
3639
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3081
3640
  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`);
3641
+ } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && !args["force-replay"]) {
3642
+ // legitimately-unsigned attestations (written when the
3643
+ // attesting host had no private key) require --force-replay to consume.
3644
+ // Pre-fix, the NOTE branch accepted them silently — which let an
3645
+ // attacker swap a valid .sig with the unsigned stub on a host that
3646
+ // happens to be private-key-absent at verify time. The cost of
3647
+ // requiring --force-replay is one explicit operator step; the benefit
3648
+ // is that any unsigned-substitution event becomes audit-visible via
3649
+ // sidecar_verify + force_replay in the emitted body.
3650
+ process.stderr.write(`[exceptd reattest] EXPLICITLY-UNSIGNED: attestation at ${attFile} carries an "unsigned" sidecar (${verify.reason}). Replay against unsigned input requires --force-replay so the audit trail captures the override.\n`);
3651
+ const body = {
3652
+ ok: false,
3653
+ error: `reattest: prior attestation is explicitly unsigned — refusing to replay without --force-replay`,
3654
+ verb: "reattest",
3655
+ session_id: sessionId,
3656
+ attestation_file: attFile,
3657
+ sidecar_verify: verify,
3658
+ hint: "If the original attestation was legitimately produced without a private key, pass --force-replay. The replay body will record sidecar_verify: 'explicitly-unsigned' + force_replay: true.",
3659
+ };
3660
+ process.stderr.write(JSON.stringify(body) + "\n");
3661
+ process.exitCode = 6;
3662
+ return;
3663
+ } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
3664
+ process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding explicitly-unsigned attestation on ${attFile}. The replay output records sidecar_verify: 'explicitly-unsigned' so the override is audit-visible.\n`);
3082
3665
  } else if (!verify.signed && verify.reason !== "no .sig sidecar") {
3083
3666
  process.stderr.write(`[exceptd reattest] NOTE: attestation at ${attFile} has no Ed25519 signature (${verify.reason}). Proceeding — unsigned attestations are an operator config issue, not tamper evidence.\n`);
3084
3667
  }
@@ -3135,6 +3718,57 @@ function cmdReattest(runner, args, runOpts, pretty) {
3135
3718
  }
3136
3719
  }
3137
3720
 
3721
+ const replayedAt = new Date().toISOString();
3722
+ const sidecarVerifyClass = classifySidecarVerify(verify);
3723
+ const forceReplay = !!args["force-replay"];
3724
+
3725
+ // KK P1-2: persist a `replay-<isoZ>.json` audit record under the session
3726
+ // directory whenever cmdReattest produced a replay verdict. Pre-fix the
3727
+ // force-replay branches emitted the override body to stdout but never
3728
+ // wrote it to disk; once the operator's shell closed the override was
3729
+ // invisible to any subsequent auditor. Now every replay writes a new
3730
+ // file alongside the original attestation.json, signed via the standard
3731
+ // maybeSignAttestation path so the audit chain remains tamper-evident.
3732
+ // The file is picked up automatically by `attest verify <sid>` (which
3733
+ // already iterates every *.json under the session dir).
3734
+ //
3735
+ // Filename shape: ISO-8601 uses ':' which the persistAttestation regex
3736
+ // refuses; substitute ':' with '-' and keep millisecond precision so
3737
+ // multiple replays in the same second do not collide on EEXIST. The
3738
+ // resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
3739
+ const replayFilename = "replay-" + replayedAt.replace(/:/g, "-") + ".json";
3740
+ let replayPersisted = null;
3741
+ try {
3742
+ const replayBody = {
3743
+ kind: "replay",
3744
+ session_id: sessionId,
3745
+ playbook_id: prior.playbook_id,
3746
+ directive_id: prior.directive_id,
3747
+ status,
3748
+ prior_evidence_hash: priorHash,
3749
+ replay_evidence_hash: newHash,
3750
+ prior_captured_at: prior.captured_at,
3751
+ replayed_at: replayedAt,
3752
+ replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3753
+ replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3754
+ sidecar_verify: verify,
3755
+ sidecar_verify_class: sidecarVerifyClass,
3756
+ force_replay: forceReplay,
3757
+ };
3758
+ const replayPath = path.join(path.dirname(attFile), replayFilename);
3759
+ // O_EXCL 'wx' — millisecond-level filename + EEXIST refusal so two
3760
+ // concurrent reattests do not silently overwrite each other.
3761
+ fs.writeFileSync(replayPath, JSON.stringify(replayBody, null, 2), { flag: "wx" });
3762
+ maybeSignAttestation(replayPath);
3763
+ replayPersisted = { ok: true, path: replayPath };
3764
+ } catch (e) {
3765
+ // Non-fatal — the stdout emit is the operator's primary surface; a
3766
+ // disk-persistence failure shouldn't mask the verdict. Surface the
3767
+ // condition in the response body so an operator-side audit pipeline
3768
+ // can re-run the persist later.
3769
+ replayPersisted = { ok: false, error: String((e && e.message) || e) };
3770
+ }
3771
+
3138
3772
  emit({
3139
3773
  ok: true,
3140
3774
  verb: "reattest",
@@ -3145,16 +3779,55 @@ function cmdReattest(runner, args, runOpts, pretty) {
3145
3779
  prior_evidence_hash: priorHash,
3146
3780
  replay_evidence_hash: newHash,
3147
3781
  prior_captured_at: prior.captured_at,
3148
- replayed_at: new Date().toISOString(),
3782
+ replayed_at: replayedAt,
3149
3783
  replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3150
3784
  replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3151
3785
  // F10: persist the sidecar verify result + the force-replay flag so the
3152
3786
  // audit trail records whether the replay was authenticated input.
3153
3787
  sidecar_verify: verify,
3154
- force_replay: !!args["force-replay"],
3788
+ // emit a one-token classification label alongside the
3789
+ // full sidecar_verify object so log scrapers / dashboards can filter on
3790
+ // override events without parsing reason strings. Values:
3791
+ // 'verified' — Ed25519 sidecar verified
3792
+ // 'tampered' — signed-but-invalid signature (post-hoc tamper)
3793
+ // 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
3794
+ // 'unsigned-substitution'— "unsigned" sidecar on a host with private key
3795
+ // (substitution attack signal)
3796
+ // 'algorithm-unsupported'— sidecar algorithm field is neither "Ed25519"
3797
+ // nor "unsigned" (downgrade-bait substitution)
3798
+ // 'explicitly-unsigned' — legitimately-unsigned attestation
3799
+ // 'no-sidecar' — sidecar file absent
3800
+ // 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
3801
+ sidecar_verify_class: sidecarVerifyClass,
3802
+ force_replay: forceReplay,
3803
+ // KK P1-2: surface the persisted replay-record path (or persistence
3804
+ // failure reason) so an auditor reading the CLI response can locate the
3805
+ // on-disk artifact without re-deriving the filename.
3806
+ replay_persisted: replayPersisted,
3155
3807
  }, pretty);
3156
3808
  }
3157
3809
 
3810
+ /**
3811
+ * map a verifyAttestationSidecar() result to a one-token
3812
+ * classification label. The label is persisted alongside the full
3813
+ * sidecar_verify object so auditors can filter override events by class
3814
+ * without regexing the human-readable reason string.
3815
+ */
3816
+ function classifySidecarVerify(verify) {
3817
+ if (!verify || typeof verify !== "object") return "unknown";
3818
+ if (verify.signed && verify.verified) return "verified";
3819
+ if (verify.signed && !verify.verified) return "tampered";
3820
+ if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
3821
+ if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
3822
+ // KK P1-3: algorithm-unsupported is its own class label so log scrapers /
3823
+ // dashboards can filter downgrade-bait events without parsing the reason.
3824
+ if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
3825
+ if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
3826
+ if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
3827
+ if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
3828
+ return "unknown";
3829
+ }
3830
+
3158
3831
  /**
3159
3832
  * `exceptd attest <subverb> <session-id>` — auditor-facing operations on
3160
3833
  * persisted attestations. Subverbs:
@@ -3261,7 +3934,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3261
3934
  const crypto = require("crypto");
3262
3935
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
3263
3936
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
3264
- // Audit Q P1 + R F6: same pin cross-check as verifyAttestationSidecar().
3937
+ // Same pin cross-check as verifyAttestationSidecar().
3265
3938
  // The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
3266
3939
  // public-key load site was not honored here — `attest verify` loaded
3267
3940
  // keys/public.pem raw. Refuse to verify any sidecar when the local
@@ -3274,13 +3947,62 @@ function cmdAttest(runner, args, runOpts, pretty) {
3274
3947
  pretty
3275
3948
  );
3276
3949
  }
3950
+ // on the verifying host, detect "unsigned" sidecar
3951
+ // substitution by checking whether .keys/private.pem is present. A
3952
+ // legitimately-unsigned attestation cannot coexist with a private key on
3953
+ // the same host — that combination is sidecar substitution (attacker
3954
+ // tampered attestation.json and overwrote .sig with the unsigned stub).
3955
+ const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3956
+ const hasPrivKey = fs.existsSync(privKeyPath);
3277
3957
  const results = files.map(f => {
3278
3958
  const sigPath = path.join(dir, f + ".sig");
3279
3959
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
3280
- const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
3281
- if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3960
+ // wrap JSON.parse so a corrupt sidecar surfaces as a
3961
+ // structured tamper-class result (signed:false, verified:false,
3962
+ // tamper_class:"sidecar-corrupt") rather than throwing into the outer
3963
+ // dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
3964
+ // generic exit-1 with no `results` array — operators piping through
3965
+ // `set -e` saw "command failed" with no tamper signal.
3966
+ let sigDoc;
3967
+ try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
3968
+ catch (e) {
3969
+ return {
3970
+ file: f,
3971
+ signed: false,
3972
+ verified: false,
3973
+ reason: `sidecar parse error: ${e.message}`,
3974
+ tamper_class: "sidecar-corrupt",
3975
+ };
3976
+ }
3977
+ if (sigDoc.algorithm === "unsigned") {
3978
+ // substitution detection.
3979
+ if (hasPrivKey) {
3980
+ return {
3981
+ file: f,
3982
+ signed: false,
3983
+ verified: false,
3984
+ reason: "attestation explicitly unsigned but .keys/private.pem IS present on this host — sidecar substitution suspected (legitimate unsigned attestations cannot exist alongside a private key)",
3985
+ tamper_class: "unsigned-substitution",
3986
+ };
3987
+ }
3988
+ return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3989
+ }
3990
+ // KK P1-3: strict algorithm check (mirrors verifyAttestationSidecar).
3991
+ // Anything that isn't exactly "Ed25519" or "unsigned" is refused as
3992
+ // tamper-class. Pre-fix null / "RSA-PSS" / arrays fell through to
3993
+ // crypto.verify with Ed25519 defaults, producing either an opaque
3994
+ // verify-throw or a downgrade-bait acceptance path.
3995
+ if (sigDoc.algorithm !== "Ed25519") {
3996
+ return {
3997
+ file: f,
3998
+ signed: false,
3999
+ verified: false,
4000
+ reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
4001
+ tamper_class: "algorithm-unsupported",
4002
+ };
4003
+ }
3282
4004
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
3283
- // Audit P P1-C: normalize before crypto.verify — mirrors the signer
4005
+ // C: normalize before crypto.verify — mirrors the signer
3284
4006
  // path so the verify pair is byte-stable across CRLF / BOM churn.
3285
4007
  const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
3286
4008
  const content = normalizeAttestationBytes(rawContent);
@@ -3300,7 +4022,20 @@ function cmdAttest(runner, args, runOpts, pretty) {
3300
4022
  // signal even when an attestation had been forged. emit()'s ok:false
3301
4023
  // → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
3302
4024
  // from generic failure, so explicitly raise to 6 (cmdReattest's code).
3303
- const tampered = results.some(r => r.signed && !r.verified);
4025
+ //
4026
+ // 2: extend the tamper predicate to cover the new
4027
+ // tamper_class variants. Pre-fix the predicate was `r.signed && !r.verified`
4028
+ // which missed (a) corrupt-JSON sidecars (signed:false) and (b) "unsigned"
4029
+ // sidecar substitution on hosts with a private key (signed:false). Both
4030
+ // are tamper-class events and must promote to exit 6.
4031
+ const tampered = results.some(r =>
4032
+ (r.signed && !r.verified)
4033
+ || r.tamper_class === "sidecar-corrupt"
4034
+ || r.tamper_class === "unsigned-substitution"
4035
+ // KK P1-3: a sidecar whose algorithm field is not "Ed25519" or
4036
+ // "unsigned" is a downgrade-bait substitution; promote to exit 6.
4037
+ || r.tamper_class === "algorithm-unsupported"
4038
+ );
3304
4039
  const body = { verb: "attest verify", session_id: sessionId, results };
3305
4040
  if (tampered) {
3306
4041
  body.ok = false;
@@ -4088,7 +4823,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4088
4823
  }
4089
4824
 
4090
4825
  function cmdListAttestations(runner, args, runOpts, pretty) {
4091
- // v0.12.14 (audit A P2-3): --playbook is registered as `multi:` so
4826
+ // v0.12.14: --playbook is registered as `multi:` so
4092
4827
  // `--playbook a --playbook b` lands as an array. The prior filter used
4093
4828
  // strict equality (`j.playbook_id !== args.playbook`) — always false for
4094
4829
  // array, silently producing count: 0. Normalize to a Set up-front.
@@ -4097,7 +4832,7 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4097
4832
  const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
4098
4833
  return new Set(list.filter(x => typeof x === "string" && x.length > 0));
4099
4834
  })();
4100
- // v0.12.14 (audit A P2-6): --since must be a parseable ISO-8601 timestamp.
4835
+ // v0.12.14: --since must be a parseable ISO-8601 timestamp.
4101
4836
  // Prior behavior silently accepted any string and lexically compared to
4102
4837
  // captured_at, producing 0-result or full-result depending on the string.
4103
4838
  if (args.since != null) {
@@ -4248,7 +4983,10 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4248
4983
  if (args.evidence) {
4249
4984
  try { payload = readEvidence(args.evidence); }
4250
4985
  catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
4251
- } else if (!process.stdin.isTTY) {
4986
+ } else if (hasReadableStdin()) {
4987
+ // EE P1-7: hasReadableStdin() probes via fstat before falling into
4988
+ // readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
4989
+ // size===0) would otherwise hang here.
4252
4990
  // Drain stdin for any evidence event.
4253
4991
  try {
4254
4992
  const buf = fs.readFileSync(0, "utf8");
@@ -4289,19 +5027,27 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4289
5027
  process.exitCode = 1;
4290
5028
  return;
4291
5029
  }
4292
- // v0.12.14 (audit A P2-1): ai-run --no-stream previously emitted a
5030
+ // v0.12.14: ai-run --no-stream previously emitted a
4293
5031
  // session_id but never persisted the attestation, so the AI agent
4294
5032
  // calling ai-run couldn't chain into `attest show / verify / diff`
4295
5033
  // or `reattest` with the returned id. Now: same persistAttestation
4296
5034
  // shape as cmdRun, so AI-facing flow round-trips cleanly.
4297
5035
  if (result.session_id) {
5036
+ // Mirror cmdRun: gate operator_consent on classification === 'detected'.
5037
+ const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
5038
+ const aiConsentApplies = aiClassification === "detected";
5039
+ if (runOpts.operator_consent && !aiConsentApplies) {
5040
+ result.ack = true;
5041
+ result.ack_applied = false;
5042
+ result.ack_skipped_reason = `classification=${aiClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
5043
+ }
4298
5044
  const persistResult = persistAttestation({
4299
5045
  sessionId: result.session_id,
4300
5046
  playbookId: result.playbook_id || playbookId,
4301
5047
  directiveId: result.directive_id || directiveId,
4302
5048
  evidenceHash: result.evidence_hash,
4303
5049
  operator: runOpts.operator,
4304
- operatorConsent: runOpts.operator_consent,
5050
+ operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
4305
5051
  submission,
4306
5052
  runOpts,
4307
5053
  forceOverwrite: !!args["force-overwrite"],
@@ -4310,12 +5056,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4310
5056
  if (!persistResult.ok && !args["force-overwrite"]) {
4311
5057
  // Collision without --force-overwrite. AI agents typically pass
4312
5058
  // unique session ids each run, so this path is rare but surface
4313
- // it cleanly via the same JSONL contract.
4314
- process.stdout.write(JSON.stringify({
5059
+ // it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
5060
+ // exit 8 set by persistAttestation when --force-overwrite hit the
5061
+ // lockfile race — don't clobber with exit 3.
5062
+ const eventBody = {
4315
5063
  event: "error", reason: persistResult.error,
4316
5064
  existing_attestation: persistResult.existingPath,
4317
- }) + "\n");
4318
- process.exitCode = 3;
5065
+ };
5066
+ if (persistResult.lock_contention) {
5067
+ eventBody.lock_contention = true;
5068
+ eventBody.exit_code = 8;
5069
+ }
5070
+ process.stdout.write(JSON.stringify(eventBody) + "\n");
5071
+ if (!persistResult.lock_contention) {
5072
+ process.exitCode = 3;
5073
+ }
4319
5074
  return;
4320
5075
  }
4321
5076
  }
@@ -4395,25 +5150,35 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4395
5150
  writeLine({ phase: "analyze", ...result.phases?.analyze });
4396
5151
  writeLine({ phase: "validate", ...result.phases?.validate });
4397
5152
  writeLine({ phase: "close", ...result.phases?.close });
4398
- // v0.12.14 (audit A P2-1): persist the attestation in streaming mode
5153
+ // v0.12.14: persist the attestation in streaming mode
4399
5154
  // too. Without this, the session_id emitted in the `done` frame
4400
5155
  // can't be resolved by `attest show / verify / diff` or `reattest`.
4401
5156
  if (result.session_id) {
5157
+ // Mirror cmdRun: gate operator_consent on classification === 'detected'.
5158
+ const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
5159
+ const aiConsentApplies = aiClassification === "detected";
4402
5160
  const persistResult = persistAttestation({
4403
5161
  sessionId: result.session_id,
4404
5162
  playbookId: result.playbook_id || playbookId,
4405
5163
  directiveId: result.directive_id || directiveId,
4406
5164
  evidenceHash: result.evidence_hash,
4407
5165
  operator: runOpts.operator,
4408
- operatorConsent: runOpts.operator_consent,
5166
+ operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
4409
5167
  submission,
4410
5168
  runOpts,
4411
5169
  forceOverwrite: !!args["force-overwrite"],
4412
5170
  filename: "attestation.json",
4413
5171
  });
4414
5172
  if (!persistResult.ok && !args["force-overwrite"]) {
4415
- writeLine({ event: "error", reason: persistResult.error,
4416
- existing_attestation: persistResult.existingPath });
5173
+ const eventBody = { event: "error", reason: persistResult.error,
5174
+ existing_attestation: persistResult.existingPath };
5175
+ if (persistResult.lock_contention) {
5176
+ eventBody.lock_contention = true;
5177
+ eventBody.exit_code = 8;
5178
+ writeLine(eventBody);
5179
+ return finish(8);
5180
+ }
5181
+ writeLine(eventBody);
4417
5182
  return finish(3);
4418
5183
  }
4419
5184
  }