@blamejs/exceptd-skills 0.12.18 → 0.12.21

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 (53) hide show
  1. package/CHANGELOG.md +224 -52
  2. package/README.md +1 -1
  3. package/bin/exceptd.js +841 -68
  4. package/data/_indexes/_meta.json +14 -14
  5. package/data/_indexes/activity-feed.json +3 -3
  6. package/data/_indexes/catalog-summaries.json +3 -3
  7. package/data/_indexes/chains.json +15 -0
  8. package/data/_indexes/jurisdiction-map.json +3 -2
  9. package/data/_indexes/section-offsets.json +175 -175
  10. package/data/_indexes/summary-cards.json +1 -1
  11. package/data/_indexes/token-budget.json +83 -83
  12. package/data/cve-catalog.json +169 -2
  13. package/data/exploit-availability.json +16 -0
  14. package/data/playbooks/ai-api.json +20 -1
  15. package/data/playbooks/containers.json +30 -0
  16. package/data/playbooks/cred-stores.json +18 -0
  17. package/data/playbooks/crypto.json +18 -0
  18. package/data/playbooks/hardening.json +26 -1
  19. package/data/playbooks/kernel.json +22 -2
  20. package/data/playbooks/mcp.json +18 -0
  21. package/data/playbooks/runtime.json +20 -1
  22. package/data/playbooks/sbom.json +18 -0
  23. package/data/playbooks/secrets.json +6 -0
  24. package/data/zeroday-lessons.json +102 -0
  25. package/lib/auto-discovery.js +68 -15
  26. package/lib/cross-ref-api.js +43 -10
  27. package/lib/cve-curation.js +4 -4
  28. package/lib/playbook-runner.js +545 -63
  29. package/lib/prefetch.js +65 -18
  30. package/lib/refresh-external.js +40 -2
  31. package/lib/refresh-network.js +100 -12
  32. package/lib/scoring.js +22 -13
  33. package/lib/sign.js +14 -6
  34. package/lib/validate-catalog-meta.js +1 -1
  35. package/lib/validate-indexes.js +2 -2
  36. package/lib/verify.js +51 -10
  37. package/manifest.json +47 -48
  38. package/orchestrator/scheduler.js +10 -0
  39. package/package.json +1 -1
  40. package/sbom.cdx.json +6 -6
  41. package/scripts/check-manifest-snapshot.js +1 -1
  42. package/scripts/check-sbom-currency.js +1 -1
  43. package/scripts/predeploy.js +10 -5
  44. package/scripts/refresh-manifest-snapshot.js +2 -2
  45. package/scripts/validate-vendor-online.js +1 -1
  46. package/scripts/verify-shipped-tarball.js +94 -6
  47. package/skills/compliance-theater/skill.md +4 -1
  48. package/skills/exploit-scoring/skill.md +20 -1
  49. package/skills/framework-gap-analysis/skill.md +6 -2
  50. package/skills/kernel-lpe-triage/skill.md +50 -3
  51. package/skills/threat-model-currency/skill.md +6 -4
  52. package/skills/webapp-security/skill.md +1 -1
  53. package/skills/zeroday-gap-learn/skill.md +44 -1
package/bin/exceptd.js CHANGED
@@ -58,6 +58,44 @@ const { spawnSync } = require("child_process");
58
58
  // (e.g. <somewhere>/node_modules/@blamejs/exceptd-skills).
59
59
  const PKG_ROOT = path.resolve(__dirname, "..");
60
60
 
61
+ /**
62
+ * Factor the EXPECTED_FINGERPRINT pin check used by
63
+ * the attestation pipeline. Centralizes the policy (compute live SHA-256
64
+ * fingerprint of the loaded public.pem, compare to keys/EXPECTED_FINGERPRINT,
65
+ * honor KEYS_ROTATED=1 bypass, tolerate missing pin file) so every site
66
+ * that loads keys/public.pem applies the same check.
67
+ *
68
+ * Returns null when the check passes (or when no pin file exists), or a
69
+ * human-readable error string when the pin diverges and the rotation env
70
+ * is not set. lib/verify.js exposes a parallel checkExpectedFingerprint()
71
+ * that operates on a precomputed fingerprint shape; this wrapper accepts
72
+ * the raw PEM directly so callers don't have to compute the fingerprint
73
+ * themselves.
74
+ */
75
+ function assertExpectedFingerprint(pubKeyPem) {
76
+ if (!pubKeyPem) return null;
77
+ const cryptoMod = require("crypto");
78
+ const pinPath = path.join(PKG_ROOT, "keys", "EXPECTED_FINGERPRINT");
79
+ if (!fs.existsSync(pinPath)) return null;
80
+ let liveFp;
81
+ try {
82
+ const ko = cryptoMod.createPublicKey(pubKeyPem);
83
+ const der = ko.export({ type: "spki", format: "der" });
84
+ liveFp = "SHA256:" + cryptoMod.createHash("sha256").update(der).digest("base64");
85
+ } catch (e) {
86
+ return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
87
+ }
88
+ const raw = fs.readFileSync(pinPath, "utf8");
89
+ const firstLine = raw.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || "";
90
+ if (firstLine === liveFp) return null;
91
+ if (process.env.KEYS_ROTATED === "1") return null;
92
+ return (
93
+ `EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
94
+ `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
95
+ `update keys/EXPECTED_FINGERPRINT.`
96
+ );
97
+ }
98
+
61
99
  // Subcommand → resolved script path. Lazy-resolved per call so a missing
62
100
  // optional component (e.g. orchestrator/) just fails that one command
63
101
  // instead of crashing dispatcher init.
@@ -453,16 +491,25 @@ function main() {
453
491
  if (typeof resolver !== "function") {
454
492
  // Emit a structured JSON error matching the seven-phase verbs so operators
455
493
  // piping through `jq` get one consistent shape across the CLI surface.
456
- // Plain-text "unknown command" still reaches stderr for human readers.
457
- const err = { ok: false, error: `unknown command "${cmd}"`, hint: "Run `exceptd help` for the list of verbs.", verb: cmd };
458
- process.stderr.write(JSON.stringify(err) + "\n");
459
- process.exit(2);
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).
499
+ emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
500
+ process.exitCode = 2;
501
+ return;
460
502
  }
461
503
 
462
504
  const script = resolver();
463
505
  if (!fs.existsSync(script)) {
464
- process.stderr.write(`exceptd: command "${cmd}" not available expected ${path.relative(PKG_ROOT, script)} in the installed package.\n`);
465
- process.exit(2);
506
+ // R-F8: same class emitError + exitCode rather than stderr + exit().
507
+ emitError(
508
+ `command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
509
+ { verb: cmd }
510
+ );
511
+ process.exitCode = 2;
512
+ return;
466
513
  }
467
514
 
468
515
  // Orchestrator subcommands need the subcommand name preserved as argv[0]
@@ -470,10 +517,15 @@ function main() {
470
517
  const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
471
518
  const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
472
519
  if (res.error) {
473
- process.stderr.write(`exceptd: failed to run ${cmd}: ${res.error.message}\n`);
474
- process.exit(2);
520
+ // R-F8: same class emitError + exitCode.
521
+ emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
522
+ process.exitCode = 2;
523
+ return;
475
524
  }
476
- process.exit(typeof res.status === "number" ? res.status : 1);
525
+ // Propagate the child's exit status via exitCode so any buffered output
526
+ // from the child (rare with stdio:"inherit", possible on Windows) gets
527
+ // a chance to drain before the parent tears down.
528
+ process.exitCode = typeof res.status === "number" ? res.status : 1;
477
529
  }
478
530
 
479
531
  // ---------------------------------------------------------------------------
@@ -555,7 +607,7 @@ function emit(obj, pretty, humanRenderer) {
555
607
  }
556
608
 
557
609
  function emitError(msg, extra, pretty) {
558
- // v0.12.14 (audit A P1-2): the v0.11.13 emit() fix used exitCode + return
610
+ // v0.12.14: the v0.11.13 emit() fix used exitCode + return
559
611
  // to defend stdout-buffered writes from truncation under piped consumers.
560
612
  // emitError() (stderr) kept process.exit(1), which has the same truncation
561
613
  // class — CLAUDE.md's "fix the class, not the instance." Now: write to
@@ -567,6 +619,50 @@ function emitError(msg, extra, pretty) {
567
619
  process.exitCode = 1;
568
620
  }
569
621
 
622
+ /**
623
+ * EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
624
+ * UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
625
+ * `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
626
+ * becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
627
+ * UTF-16 as garbage. Route every operator-supplied JSON file through here.
628
+ *
629
+ * 1. read as Buffer
630
+ * 2. detect BOM (UTF-16 LE / BE / UTF-8 BOM)
631
+ * 3. decode appropriately, strip leading BOM if present
632
+ * 4. JSON.parse
633
+ *
634
+ * On parse failure, throw a clean message that preserves the operator-facing
635
+ * path but does NOT leak the raw V8 parser stack — operators see "failed to
636
+ * parse JSON at <path>: <reason>", not a 12-line trace.
637
+ */
638
+ function readJsonFile(filePath) {
639
+ let buf;
640
+ try { buf = fs.readFileSync(filePath); }
641
+ catch (e) { throw new Error(`failed to read ${filePath}: ${e.message}`); }
642
+ let text;
643
+ if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) {
644
+ text = buf.slice(2).toString("utf16le");
645
+ } else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
646
+ // UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
647
+ const swapped = Buffer.allocUnsafe(buf.length - 2);
648
+ for (let i = 2; i < buf.length - 1; i += 2) {
649
+ swapped[i - 2] = buf[i + 1];
650
+ swapped[i - 1] = buf[i];
651
+ }
652
+ text = swapped.toString("utf16le");
653
+ } else if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
654
+ text = buf.slice(3).toString("utf8");
655
+ } else {
656
+ text = buf.toString("utf8");
657
+ }
658
+ // Belt-and-braces: strip any residual leading U+FEFF the decode may have left.
659
+ if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
660
+ try { return JSON.parse(text); }
661
+ catch (e) {
662
+ throw new Error(`failed to parse JSON at ${filePath}: ${e.message}`);
663
+ }
664
+ }
665
+
570
666
  function readEvidence(evidenceFlag) {
571
667
  if (!evidenceFlag) return {};
572
668
  if (evidenceFlag === "-") {
@@ -585,13 +681,87 @@ function readEvidence(evidenceFlag) {
585
681
  if (stat.size > MAX_EVIDENCE_BYTES) {
586
682
  throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
587
683
  }
588
- return JSON.parse(fs.readFileSync(evidenceFlag, "utf8"));
684
+ // EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
685
+ // Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
686
+ // decode in readFileSync chokes on the leading 0xFEFF.
687
+ return readJsonFile(evidenceFlag);
589
688
  }
590
689
 
591
690
  function loadRunner() {
592
691
  return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
593
692
  }
594
693
 
694
+ /**
695
+ * EE P1-7: detect whether stdin actually has data without blocking.
696
+ *
697
+ * `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
698
+ * `false`, `undefined`, OR `null`. Test harnesses with custom stdin
699
+ * duplexers (Mocha/Jest, some Docker stdin-passthrough wrappers) leave
700
+ * isTTY === undefined but never write any bytes — falling into
701
+ * `fs.readFileSync(0, "utf8")` then BLOCKS waiting for an EOF that
702
+ * never arrives.
703
+ *
704
+ * Strategy:
705
+ *
706
+ * 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).
716
+ *
717
+ * Returns `true` if the caller may safely fs.readFileSync(0) without
718
+ * risking an indefinite block on a wrapped empty stream.
719
+ */
720
+ function hasReadableStdin() {
721
+ if (process.stdin.isTTY) return false;
722
+ let st;
723
+ try { st = fs.fstatSync(0); }
724
+ catch { return !process.stdin.isTTY; /* fstat failed — fall back */ }
725
+ // POSIX pipes / FIFOs / sockets / character devices report size 0
726
+ // even when bytes are queued (or about to be). Trust them — a real
727
+ // `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
728
+ // will read to EOF cleanly. If the write end is open and no bytes
729
+ // arrive, the read blocks — that's the operator's contract, not the
730
+ // CLI's to second-guess. Wrapped test harnesses that never write
731
+ // should pass `--evidence -` explicitly.
732
+ if (typeof st.isFIFO === "function" && st.isFIFO()) return true;
733
+ if (typeof st.isSocket === "function" && st.isSocket()) return true;
734
+ if (typeof st.isCharacterDevice === "function" && st.isCharacterDevice()) return true;
735
+ // Regular file (e.g. `exceptd run <evidence.json` shell redirect).
736
+ // size 0 here means a legitimately empty file.
737
+ if (typeof st.size === "number" && st.size > 0) return true;
738
+ // Windows fallback: pipes don't surface as FIFOs via fstat on win32
739
+ // (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;
742
+ return false;
743
+ }
744
+
745
+ /**
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.
753
+ *
754
+ * Returns null on success; returns the human-facing error message string
755
+ * on failure so the caller can wrap it with its own verb prefix.
756
+ */
757
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
758
+ function validateIsoSince(raw) {
759
+ if (typeof raw !== "string" || !ISO_DATE_RE.test(raw) || isNaN(Date.parse(raw))) {
760
+ return `--since must be a parseable ISO-8601 calendar timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(raw)).slice(0, 80)}`;
761
+ }
762
+ return null;
763
+ }
764
+
595
765
  /**
596
766
  * F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
597
767
  * OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
@@ -613,8 +783,21 @@ function detectVexShape(doc) {
613
783
  // entries look vex-shaped (have id/bom_ref/analysis).
614
784
  if (Array.isArray(doc.vulnerabilities)) {
615
785
  const isBom = doc.bomFormat === "CycloneDX";
616
- const entriesLookVex = doc.vulnerabilities.length === 0
617
- || doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
786
+ const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
787
+ 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
791
+ // `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).
794
+ if (doc.vulnerabilities.length === 0) {
795
+ if (hasCyclonedxMarker) {
796
+ return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
797
+ }
798
+ return { ok: false, detected: "empty-vulnerabilities-without-cyclonedx-marker", top_level_keys: keys };
799
+ }
800
+ const entriesLookVex = doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
618
801
  if (isBom || entriesLookVex) {
619
802
  return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
620
803
  }
@@ -632,8 +815,17 @@ function detectVexShape(doc) {
632
815
  if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
633
816
  return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
634
817
  }
635
- if (doc.bomFormat === "CycloneDX" && !Array.isArray(doc.vulnerabilities)) {
636
- return { ok: false, detected: "cyclonedx-sbom-without-vulnerabilities", top_level_keys: keys };
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
820
+ // exploitable. Accept it as cyclonedx-vex with an empty filter set (the
821
+ // runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
822
+ // for documents that carry a CycloneDX-flavored specVersion ("1.x") without
823
+ // bomFormat — Windows tooling sometimes drops the marker on export.
824
+ const cyclonedxMarker =
825
+ doc.bomFormat === "CycloneDX" ||
826
+ (typeof doc.specVersion === "string" && /^1\./.test(doc.specVersion));
827
+ if (cyclonedxMarker && !Array.isArray(doc.vulnerabilities)) {
828
+ return { ok: true, detected: "cyclonedx-vex-zero-cve", top_level_keys: keys };
637
829
  }
638
830
  if (Array.isArray(doc.statements) && !ctxStr) {
639
831
  return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
@@ -778,13 +970,137 @@ function dispatchPlaybook(cmd, argv) {
778
970
  pretty
779
971
  );
780
972
  }
781
- runOpts.operator = args.operator;
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"),
975
+ // zero-width joiners (U+200B-D), invisible format chars, private-use
976
+ // codepoints, unassigned codepoints. An operator string like
977
+ // "alice‮evilbob" renders as "alicebobevila" in any UI that respects
978
+ // bidi — a forgery surface where the attested name looks like Bob but the
979
+ // bytes are Alice. Reject anything outside a positive allowlist of
980
+ // printable ASCII + most BMP printable codepoints (skipping the format /
981
+ // control / surrogate gaps).
982
+ //
983
+ // Implementation: NFC-normalise first (so a decomposed sequence can't
984
+ // smuggle a combining mark past the codepoint check), then iterate
985
+ // codepoints and refuse Cc/Cf/Co/Cn. We use \p{C} via the `u` regex flag,
986
+ // which matches Cc + Cf + Cs + Co + Cn in one shot. Unicode 15.1 is the
987
+ // baseline supported by Node 20.
988
+ let normalized;
989
+ try { normalized = args.operator.normalize("NFC"); }
990
+ catch (e) {
991
+ return emitError(
992
+ `run: --operator failed Unicode NFC normalisation: ${e.message}`,
993
+ { provided_length: args.operator.length },
994
+ pretty
995
+ );
996
+ }
997
+ if (normalized.length === 0) {
998
+ return emitError(
999
+ "run: --operator is empty after Unicode NFC normalisation. Pass a meaningful identifier or omit the flag.",
1000
+ null,
1001
+ pretty
1002
+ );
1003
+ }
1004
+ if (/\p{C}/u.test(normalized)) {
1005
+ // Find the offending codepoint to surface a useful hint without
1006
+ // round-tripping the raw bytes into the error body.
1007
+ let offending = "";
1008
+ for (const cp of normalized) {
1009
+ if (/\p{C}/u.test(cp)) {
1010
+ offending = "U+" + cp.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
1011
+ break;
1012
+ }
1013
+ }
1014
+ return emitError(
1015
+ `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.`,
1016
+ { provided_length: args.operator.length, offending_codepoint: offending },
1017
+ pretty
1018
+ );
1019
+ }
1020
+ runOpts.operator = normalized;
1021
+ }
1022
+
1023
+ // audit CC P1-3: --publisher-namespace <url> threads into the CSAF
1024
+ // bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
1025
+ // namespace to be the publisher's trust anchor — i.e. the OPERATOR
1026
+ // running the scan, not the tooling vendor. Pre-fix this was hard-coded
1027
+ // to https://exceptd.com, misattributing responsibility for advisory
1028
+ // accuracy. Validation mirrors --operator (string, ≤256 chars, no
1029
+ // ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
1030
+ if (args["publisher-namespace"] !== undefined) {
1031
+ const ns = args["publisher-namespace"];
1032
+ if (typeof ns !== "string") {
1033
+ return emitError("run: --publisher-namespace must be a string.", { provided: typeof ns }, pretty);
1034
+ }
1035
+ // eslint-disable-next-line no-control-regex
1036
+ if (/[\x00-\x1F\x7F]/.test(ns)) {
1037
+ return emitError(
1038
+ "run: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.",
1039
+ { provided_length: ns.length },
1040
+ pretty
1041
+ );
1042
+ }
1043
+ if (ns.length === 0 || ns.length > 256) {
1044
+ return emitError(
1045
+ `run: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
1046
+ { provided_length: ns.length },
1047
+ pretty
1048
+ );
1049
+ }
1050
+ if (!/^https?:\/\//i.test(ns)) {
1051
+ 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.",
1053
+ { provided: ns.slice(0, 80) },
1054
+ pretty
1055
+ );
1056
+ }
1057
+ runOpts.publisherNamespace = ns;
1058
+ }
1059
+
1060
+ // audit CC P1-1: --csaf-status promotes the CSAF tracking.status from the
1061
+ // runtime default (`interim`) to `final` for operators who have reviewed
1062
+ // the advisory and accept the immutable-advisory contract of CSAF
1063
+ // §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
1064
+ // rejected at input so an operator typo (`finel`) doesn't silently fall
1065
+ // back to interim and produce surprise.
1066
+ if (args["csaf-status"] !== undefined) {
1067
+ const cs = args["csaf-status"];
1068
+ const allowed = ["draft", "interim", "final"];
1069
+ if (typeof cs !== "string" || !allowed.includes(cs)) {
1070
+ return emitError(
1071
+ `run: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
1072
+ { provided: cs },
1073
+ pretty
1074
+ );
1075
+ }
1076
+ runOpts.csafStatus = cs;
782
1077
  }
1078
+
783
1079
  // --ack: operator acknowledges the jurisdiction obligations surfaced by
784
1080
  // govern. Captured in attestation; downstream tooling can check whether
785
1081
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
786
1082
  // and wait for ack — this is how the ack gets recorded.
787
- if (args.ack) runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
1083
+ //
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.
1091
+ const ACK_RELEVANT_VERBS = new Set([
1092
+ "run", "ingest", "ai-run", "ci", "run-all", "reattest",
1093
+ ]);
1094
+ if (args.ack) {
1095
+ if (!ACK_RELEVANT_VERBS.has(cmd)) {
1096
+ return emitError(
1097
+ `${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.`,
1098
+ { verb: cmd, accepted_verbs: [...ACK_RELEVANT_VERBS].sort() },
1099
+ pretty
1100
+ );
1101
+ }
1102
+ runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
1103
+ }
788
1104
 
789
1105
  let runner;
790
1106
  try {
@@ -1124,7 +1440,7 @@ Flags:
1124
1440
  Stdin event grammar (one JSON object per line):
1125
1441
  {"event":"evidence","payload":{"observations":{},"verdict":{}}}
1126
1442
 
1127
- Stdin acceptance contract (Audit L F22):
1443
+ Stdin acceptance contract:
1128
1444
  In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
1129
1445
  parseable {"event":"evidence","payload":{...}} line. That line wins:
1130
1446
  subsequent evidence events on the same run are ignored (the handler
@@ -1558,7 +1874,7 @@ function cmdPlan(runner, args, runOpts, pretty) {
1558
1874
  emit(plan, pretty);
1559
1875
  }
1560
1876
 
1561
- // v0.12.15 (audit L F1, F2): --scope must validate against the accepted
1877
+ // v0.12.15: --scope must validate against the accepted
1562
1878
  // set. The prior shape silently returned [] for any unknown scope, which
1563
1879
  // in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
1564
1880
  // "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
@@ -1648,7 +1964,14 @@ function cmdRun(runner, args, runOpts, pretty) {
1648
1964
  // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
1649
1965
  // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
1650
1966
  // from the cwd.
1651
- if (!positional && (args.all || args.scope)) {
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.
1974
+ if (!positional && (args.all || args.scope !== undefined)) {
1652
1975
  let ids;
1653
1976
  if (args.all) {
1654
1977
  ids = runner.listPlaybooks();
@@ -1658,7 +1981,7 @@ function cmdRun(runner, args, runOpts, pretty) {
1658
1981
  }
1659
1982
  return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
1660
1983
  }
1661
- if (!positional && !args.all && !args.scope) {
1984
+ if (!positional && !args.all && args.scope === undefined) {
1662
1985
  const scopes = detectScopes();
1663
1986
  const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
1664
1987
  const unique = [...new Set(ids)];
@@ -1722,11 +2045,24 @@ function cmdRun(runner, args, runOpts, pretty) {
1722
2045
  }
1723
2046
 
1724
2047
  let submission = {};
1725
- // v0.11.1: auto-detect piped stdin (process.stdin.isTTY === false means
1726
- // something is piping into us). If no --evidence flag and stdin is a pipe,
1727
- // assume `--evidence -`. Operators forgetting the flag previously got a
1728
- // confusing precondition halt; now the common case "just works."
1729
- if (!args.evidence && process.stdin.isTTY === false) {
2048
+ // v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
2049
+ // pipe, assume `--evidence -`. Operators forgetting the flag previously
2050
+ // 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).
2065
+ if (!args.evidence && hasReadableStdin()) {
1730
2066
  args.evidence = "-";
1731
2067
  }
1732
2068
  if (args.evidence) {
@@ -1761,8 +2097,32 @@ function cmdRun(runner, args, runOpts, pretty) {
1761
2097
  // CVE ID set through to analyze() so matched_cves drops them.
1762
2098
  if (args.vex) {
1763
2099
  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
2105
+ // any legitimate VEX submission.
2106
+ const MAX_VEX_BYTES = 32 * 1024 * 1024;
2107
+ let vstat;
2108
+ try { vstat = fs.statSync(args.vex); }
2109
+ catch (e) {
2110
+ return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
2111
+ }
2112
+ if (vstat.size > MAX_VEX_BYTES) {
2113
+ // EE P1-4: error message names the binary mebi convention explicitly so
2114
+ // operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
2115
+ return emitError(
2116
+ `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.`,
2117
+ { provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
2118
+ pretty
2119
+ );
2120
+ }
1764
2121
  try {
1765
- vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
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
2124
+ // readFileSync chokes on the leading 0xFEFF.
2125
+ vexDoc = readJsonFile(args.vex);
1766
2126
  } catch (e) {
1767
2127
  return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
1768
2128
  }
@@ -1784,6 +2144,16 @@ function cmdRun(runner, args, runOpts, pretty) {
1784
2144
  const vexSet = runner.vexFilterFromDoc(vexDoc);
1785
2145
  submission.signals = submission.signals || {};
1786
2146
  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.
2156
+ submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
1787
2157
  } catch (e) {
1788
2158
  return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
1789
2159
  }
@@ -1820,9 +2190,27 @@ function cmdRun(runner, args, runOpts, pretty) {
1820
2190
  // v0.11.10 (#119): add result.ack alias for consumers reading the
1821
2191
  // ack state by that name (`result.ack` is shorter + matches the CLI flag).
1822
2192
  if (result && runOpts.operator) result.operator = runOpts.operator;
2193
+
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
2196
+ // 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.
2202
+ const detectClassification = result && result.phases && result.phases.detect
2203
+ ? result.phases.detect.classification
2204
+ : null;
2205
+ const consentApplies =
2206
+ !!runOpts.operator_consent && detectClassification === "detected";
1823
2207
  if (result && runOpts.operator_consent) {
1824
2208
  result.operator_consent = runOpts.operator_consent;
1825
2209
  result.ack = !!runOpts.operator_consent.explicit;
2210
+ result.ack_applied = consentApplies;
2211
+ if (!consentApplies) {
2212
+ result.ack_skipped_reason = `classification=${detectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2213
+ }
1826
2214
  } else if (result) {
1827
2215
  result.ack = false;
1828
2216
  }
@@ -1835,7 +2223,8 @@ function cmdRun(runner, args, runOpts, pretty) {
1835
2223
  directiveId: result.directive_id,
1836
2224
  evidenceHash: result.evidence_hash,
1837
2225
  operator: runOpts.operator,
1838
- operatorConsent: runOpts.operator_consent,
2226
+ // EE P1-6: gate consent persistence on classification=detected.
2227
+ operatorConsent: consentApplies ? runOpts.operator_consent : null,
1839
2228
  submission,
1840
2229
  runOpts,
1841
2230
  forceOverwrite: !!args["force-overwrite"],
@@ -1853,7 +2242,7 @@ function cmdRun(runner, args, runOpts, pretty) {
1853
2242
  hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
1854
2243
  verb: "run",
1855
2244
  };
1856
- // v0.12.14 (audit A P1-2): exitCode + return instead of process.exit
2245
+ // v0.12.14: exitCode + return instead of process.exit
1857
2246
  // so the stderr line drains under piped CI consumers.
1858
2247
  process.stderr.write(JSON.stringify(err) + "\n");
1859
2248
  process.exitCode = 3;
@@ -2198,10 +2587,18 @@ function buildJurisdictionClockRollup(results) {
2198
2587
  existing.deadline = n.deadline;
2199
2588
  }
2200
2589
  } 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
2592
+ // for any consumer that already parses the older shape. The dedupe
2593
+ // key still keys on n.obligation_ref since that's the field
2594
+ // notification-action stubs carry; the rollup body just exposes
2595
+ // both names so the documented contract is truthful.
2596
+ const obligation = n.obligation_ref || null;
2201
2597
  m.set(key, {
2202
2598
  jurisdiction: n.jurisdiction || null,
2203
2599
  regulation: n.regulation || null,
2204
- obligation_ref: n.obligation_ref || null,
2600
+ obligation,
2601
+ obligation_ref: obligation,
2205
2602
  window_hours: n.window_hours ?? null,
2206
2603
  clock_started_at: n.clock_started_at,
2207
2604
  deadline: n.deadline || null,
@@ -2249,6 +2646,52 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2249
2646
  if (!entryPath.startsWith(resolvedDir + path.sep)) {
2250
2647
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
2251
2648
  }
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.
2656
+ let lst;
2657
+ try { lst = fs.lstatSync(entryPath); }
2658
+ catch (e) {
2659
+ return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
2660
+ }
2661
+ if (lst.isSymbolicLink()) {
2662
+ return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
2663
+ }
2664
+ if (!lst.isFile()) {
2665
+ return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2666
+ }
2667
+ // EE P1-5: Windows directory junctions are reparse-point dirs that
2668
+ // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
2669
+ // ordinary directories). They bypass the symlink refusal above. Use
2670
+ // realpathSync to resolve the entry and confirm it still lives under
2671
+ // the resolved evidence-dir — the realpath approach is portable
2672
+ // (catches POSIX symlinks too, defense in depth) and works regardless
2673
+ // of whether the OS exposes reparse-point bits.
2674
+ let realEntry;
2675
+ try { realEntry = fs.realpathSync(entryPath); }
2676
+ catch (e) {
2677
+ return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
2678
+ }
2679
+ if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
2680
+ return emitError(
2681
+ `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
2682
+ { entry: f, resolved_to: realEntry },
2683
+ pretty
2684
+ );
2685
+ }
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 —
2690
+ // legitimate use cases (atomic rename, package-manager dedup) produce
2691
+ // nlink > 1 without malicious intent.
2692
+ if (lst.nlink > 1) {
2693
+ 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`);
2694
+ }
2252
2695
  try {
2253
2696
  bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
2254
2697
  } catch (e) {
@@ -2340,7 +2783,16 @@ function cmdIngest(runner, args, runOpts, pretty) {
2340
2783
  // `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
2341
2784
  // because args.evidence stayed undefined and the routing JSON never got
2342
2785
  // read. Mirrors the cmdRun behavior at line 1614.
2343
- if (!args.evidence && process.stdin.isTTY === false) {
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.
2795
+ if (!args.evidence && hasReadableStdin()) {
2344
2796
  args.evidence = "-";
2345
2797
  }
2346
2798
  if (args.evidence) {
@@ -2539,14 +2991,89 @@ function persistAttestation(args) {
2539
2991
  existingPath: path.relative(process.cwd(), filePath),
2540
2992
  };
2541
2993
  }
2542
- writeAttestation(prior ? (prior.evidence_hash || null) : null,
2543
- prior ? (prior.captured_at || null) : null,
2544
- "w");
2545
- return {
2546
- ok: true,
2547
- prior_session_id: prior ? sessionId : null,
2548
- overwrote_at: prior ? prior.captured_at : null,
2549
- };
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.
3009
+ const lockPath = filePath + ".lock";
3010
+ const MAX_RETRIES = 10;
3011
+ const STALE_LOCK_MS = 30_000;
3012
+ let acquired = false;
3013
+ for (let i = 0; i < MAX_RETRIES; i++) {
3014
+ try {
3015
+ fs.writeFileSync(lockPath, String(process.pid), { flag: "wx" });
3016
+ acquired = true;
3017
+ break;
3018
+ } catch (lockErr) {
3019
+ if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
3020
+ let reclaimed = false;
3021
+ try {
3022
+ const raw = fs.readFileSync(lockPath, "utf8").trim();
3023
+ const pid = Number.parseInt(raw, 10);
3024
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
3025
+ try { process.kill(pid, 0); }
3026
+ catch (probeErr) {
3027
+ if (probeErr && probeErr.code === "ESRCH") {
3028
+ try { fs.unlinkSync(lockPath); reclaimed = true; } catch {}
3029
+ }
3030
+ }
3031
+ }
3032
+ } catch {}
3033
+ if (reclaimed) continue;
3034
+ try {
3035
+ const stat = fs.statSync(lockPath);
3036
+ if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
3037
+ try { fs.unlinkSync(lockPath); } catch {}
3038
+ continue;
3039
+ }
3040
+ } catch {}
3041
+ // Synchronous spin — persistAttestation is sync; we cannot await.
3042
+ const deadline = Date.now() + 50 + Math.floor(Math.random() * 150);
3043
+ while (Date.now() < deadline) { /* spin */ }
3044
+ }
3045
+ }
3046
+ 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.
3052
+ return {
3053
+ ok: false,
3054
+ 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
+ existingPath: path.relative(process.cwd(), filePath),
3056
+ lock_contention: true,
3057
+ };
3058
+ }
3059
+ try {
3060
+ // Re-read prior INSIDE the lock — the value captured before lock
3061
+ // acquisition may be stale if another --force-overwrite landed
3062
+ // between our EEXIST probe and the lock grab.
3063
+ let lockedPrior = prior;
3064
+ try { lockedPrior = JSON.parse(fs.readFileSync(filePath, "utf8")); }
3065
+ catch { /* keep pre-lock prior */ }
3066
+ writeAttestation(lockedPrior ? (lockedPrior.evidence_hash || null) : null,
3067
+ lockedPrior ? (lockedPrior.captured_at || null) : null,
3068
+ "w");
3069
+ return {
3070
+ ok: true,
3071
+ prior_session_id: lockedPrior ? sessionId : null,
3072
+ overwrote_at: lockedPrior ? lockedPrior.captured_at : null,
3073
+ };
3074
+ } finally {
3075
+ try { fs.unlinkSync(lockPath); } catch {}
3076
+ }
2550
3077
  }
2551
3078
  } catch (e) {
2552
3079
  return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
@@ -2563,6 +3090,20 @@ function persistAttestation(args) {
2563
3090
  * state so downstream tooling can distinguish "operator declined signing"
2564
3091
  * from "the .sig file was deleted by an attacker."
2565
3092
  */
3093
+ /**
3094
+ * C: byte-stability normalize() for the attestation pipeline.
3095
+ * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
3096
+ * normalize() implementations in lib/sign.js, lib/verify.js,
3097
+ * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
3098
+ * sites total; tests/normalize-contract.test.js asserts byte-identical
3099
+ * output across all of them.
3100
+ */
3101
+ function normalizeAttestationBytes(input) {
3102
+ let s = Buffer.isBuffer(input) ? input.toString("utf8") : String(input);
3103
+ if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
3104
+ return s.replace(/\r\n/g, "\n");
3105
+ }
3106
+
2566
3107
  function maybeSignAttestation(filePath) {
2567
3108
  const crypto = require("crypto");
2568
3109
  const sigPath = filePath + ".sig";
@@ -2576,7 +3117,16 @@ function maybeSignAttestation(filePath) {
2576
3117
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
2577
3118
  // verifier doesn't trust.
2578
3119
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
2579
- const content = fs.readFileSync(filePath, "utf8");
3120
+ // C: normalize attestation bytes before sign — strip leading
3121
+ // UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
3122
+ // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
3123
+ // attestation file lives on disk under .exceptd/ and can pick up CRLF
3124
+ // through git-attribute / editor round-trips on Windows; without
3125
+ // normalization the sign/verify pair diverges on the same logical content.
3126
+ // The byte-stability contract is now five sites; tests/normalize-contract
3127
+ // .test.js enforces byte-identical output across all of them.
3128
+ const rawContent = fs.readFileSync(filePath, "utf8");
3129
+ const content = normalizeAttestationBytes(rawContent);
2580
3130
  // One-time-per-process unsigned warning so cron jobs don't spam stderr.
2581
3131
  // Operators who set `.keys/private.pem` get tamper-evident attestations;
2582
3132
  // operators without the keypair get a single nudge per session telling them
@@ -2623,7 +3173,7 @@ function maybeSignAttestation(filePath) {
2623
3173
  * Returns null if neither has the session.
2624
3174
  */
2625
3175
  /**
2626
- * v0.12.14 (audit A P1-1): session-id validation — applied at every READ
3176
+ * v0.12.14: session-id validation — applied at every READ
2627
3177
  * site, not just writes. The write path (persistAttestation) was hardened
2628
3178
  * in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
2629
3179
  * accepted arbitrary strings and joined them into path.join(root, id) with
@@ -2642,7 +3192,7 @@ function validateSessionIdForRead(sessionId) {
2642
3192
  }
2643
3193
 
2644
3194
  function findSessionDir(sessionId, runOpts) {
2645
- // v0.12.14 (audit A P1-1): validate the session-id at every read path.
3195
+ // v0.12.14: validate the session-id at every read path.
2646
3196
  try { validateSessionIdForRead(sessionId); }
2647
3197
  catch { return null; }
2648
3198
  const candidates = [
@@ -2718,20 +3268,70 @@ function verifyAttestationSidecar(attFile) {
2718
3268
  const sigPath = attFile + ".sig";
2719
3269
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2720
3270
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
3271
+ // Consult keys/EXPECTED_FINGERPRINT before honoring
3272
+ // the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
3273
+ // at every public-key load site," but reattest's signature verifier
3274
+ // loaded keys/public.pem without the pin cross-check. A coordinated
3275
+ // attacker who swapped keys/public.pem on the operator's host could
3276
+ // verify-against-attacker-key without surfacing the divergence. Honors
3277
+ // KEYS_ROTATED=1 to bypass during legitimate rotation.
3278
+ if (pubKey) {
3279
+ const pinError = assertExpectedFingerprint(pubKey);
3280
+ if (pinError) {
3281
+ return { file: attFile, signed: false, verified: false, reason: pinError };
3282
+ }
3283
+ }
2721
3284
  if (!fs.existsSync(sigPath)) {
2722
3285
  return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
2723
3286
  }
2724
3287
  let sigDoc;
2725
3288
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
2726
- catch (e) { return { file: attFile, signed: false, verified: false, reason: `sidecar parse error: ${e.message}` }; }
3289
+ catch (e) {
3290
+ // Audit AA P1-2: a corrupt-JSON sidecar is observationally indistinguishable
3291
+ // from sidecar tamper — an attacker who can rewrite attestation.json can
3292
+ // also truncate / mangle the .sig file. Surface as a distinct
3293
+ // tamper-class reason so callers can require --force-replay. Pre-fix,
3294
+ // cmdReattest only refused on `reason === "no .sig sidecar"`; a
3295
+ // parse-error reason fell through to the benign NOTE branch and replay
3296
+ // proceeded against forged input.
3297
+ return {
3298
+ file: attFile,
3299
+ signed: false,
3300
+ verified: false,
3301
+ reason: `sidecar parse error: ${e.message}`,
3302
+ tamper_class: "sidecar-corrupt",
3303
+ };
3304
+ }
2727
3305
  if (sigDoc.algorithm === "unsigned") {
3306
+ // Audit AA P1-1: `algorithm: "unsigned"` is only legitimate when written
3307
+ // by maybeSignAttestation() at attestation-creation time on a host
3308
+ // WITHOUT .keys/private.pem. If the verifying host HAS a private key,
3309
+ // an "unsigned" sidecar is a substitution attack: tamper attestation.json
3310
+ // (breaking Ed25519) then overwrite .sig with the unsigned stub to bypass
3311
+ // the tamper detector. Promote to tamper-class so callers can refuse.
3312
+ const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3313
+ if (fs.existsSync(privKeyPath)) {
3314
+ return {
3315
+ file: attFile,
3316
+ signed: false,
3317
+ verified: false,
3318
+ 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)",
3319
+ tamper_class: "unsigned-substitution",
3320
+ };
3321
+ }
2728
3322
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
2729
3323
  }
2730
3324
  if (!pubKey) {
2731
3325
  return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
2732
3326
  }
2733
3327
  let content;
2734
- try { content = fs.readFileSync(attFile, "utf8"); }
3328
+ try {
3329
+ 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).
3333
+ content = normalizeAttestationBytes(raw);
3334
+ }
2735
3335
  catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
2736
3336
  try {
2737
3337
  const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
@@ -2754,12 +3354,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
2754
3354
  // through to walkAttestationDir, where the lexical comparison either
2755
3355
  // matched all or none unpredictably.
2756
3356
  if (args.since != null) {
2757
- if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
2758
- return emitError(
2759
- `reattest: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
2760
- null, pretty
2761
- );
2762
- }
3357
+ // R-F10: regex BEFORE Date.parse bare integers like "99" would
3358
+ // otherwise parse as the year 1999 and silently filter wrong eras.
3359
+ const sinceErr = validateIsoSince(args.since);
3360
+ if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
2763
3361
  }
2764
3362
  // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
2765
3363
  // without requiring the operator to know the session-id.
@@ -2790,7 +3388,16 @@ function cmdReattest(runner, args, runOpts, pretty) {
2790
3388
  // tampering. `verified === false && signed === true` is the real tamper
2791
3389
  // signal.
2792
3390
  const verify = verifyAttestationSidecar(attFile);
2793
- if (verify.signed && !verify.verified && !args["force-replay"]) {
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.
3398
+ const isSignedTamper = verify.signed && !verify.verified;
3399
+ const isClassTamper = !verify.signed && (verify.tamper_class === "sidecar-corrupt" || verify.tamper_class === "unsigned-substitution");
3400
+ if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
2794
3401
  process.stderr.write(`[exceptd reattest] TAMPERED: attestation at ${attFile} failed Ed25519 verification (${verify.reason}). Refusing to replay against forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
2795
3402
  const body = {
2796
3403
  ok: false,
@@ -2805,8 +3412,58 @@ function cmdReattest(runner, args, runOpts, pretty) {
2805
3412
  process.exitCode = 6;
2806
3413
  return;
2807
3414
  }
2808
- if (verify.signed && !verify.verified && args["force-replay"]) {
3415
+ if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
2809
3416
  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`);
3417
+ } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && !args["force-replay"]) {
3418
+ // missing-sidecar is NOT benign. The previous flow accepted
3419
+ // a missing .sig file silently (only blocked on signed-but-invalid).
3420
+ // Sidecar deletion is observationally identical to sidecar tamper —
3421
+ // an attacker who can rewrite the attestation can also rm the sidecar,
3422
+ // and pre-fix that path produced a green replay with no audit warning.
3423
+ // Now: refuse unless --force-replay, and the persisted replay body
3424
+ // records sidecar_verify so the override is audit-visible. Operators
3425
+ // whose original run wrote unsigned attestations (no private key
3426
+ // available) hit the "explicitly unsigned" branch below, which is
3427
+ // distinguishable from a missing sidecar.
3428
+ process.stderr.write(`[exceptd reattest] TAMPERED-OR-MISSING: no .sig sidecar at ${attFile}.sig. Sidecar deletion is treated the same as sidecar tamper — refusing to replay against potentially-forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
3429
+ const body = {
3430
+ ok: false,
3431
+ error: `reattest: prior attestation has no .sig sidecar — refusing to replay`,
3432
+ verb: "reattest",
3433
+ session_id: sessionId,
3434
+ attestation_file: attFile,
3435
+ sidecar_verify: verify,
3436
+ hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
3437
+ };
3438
+ process.stderr.write(JSON.stringify(body) + "\n");
3439
+ process.exitCode = 6;
3440
+ return;
3441
+ } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3442
+ 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
+ } 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
3445
+ // attesting host had no private key) require --force-replay to consume.
3446
+ // Pre-fix, the NOTE branch accepted them silently — which let an
3447
+ // attacker swap a valid .sig with the unsigned stub on a host that
3448
+ // happens to be private-key-absent at verify time. The cost of
3449
+ // requiring --force-replay is one explicit operator step; the benefit
3450
+ // is that any unsigned-substitution event becomes audit-visible via
3451
+ // sidecar_verify + force_replay in the emitted body.
3452
+ 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`);
3453
+ const body = {
3454
+ ok: false,
3455
+ error: `reattest: prior attestation is explicitly unsigned — refusing to replay without --force-replay`,
3456
+ verb: "reattest",
3457
+ session_id: sessionId,
3458
+ attestation_file: attFile,
3459
+ sidecar_verify: verify,
3460
+ 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.",
3461
+ };
3462
+ process.stderr.write(JSON.stringify(body) + "\n");
3463
+ process.exitCode = 6;
3464
+ return;
3465
+ } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
3466
+ 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`);
2810
3467
  } else if (!verify.signed && verify.reason !== "no .sig sidecar") {
2811
3468
  process.stderr.write(`[exceptd reattest] NOTE: attestation at ${attFile} has no Ed25519 signature (${verify.reason}). Proceeding — unsigned attestations are an operator config issue, not tamper evidence.\n`);
2812
3469
  }
@@ -2879,10 +3536,40 @@ function cmdReattest(runner, args, runOpts, pretty) {
2879
3536
  // F10: persist the sidecar verify result + the force-replay flag so the
2880
3537
  // audit trail records whether the replay was authenticated input.
2881
3538
  sidecar_verify: verify,
3539
+ // Audit AA P1-1: emit a one-token classification label alongside the
3540
+ // full sidecar_verify object so log scrapers / dashboards can filter on
3541
+ // override events without parsing reason strings. Values:
3542
+ // 'verified' — Ed25519 sidecar verified
3543
+ // 'tampered' — signed-but-invalid signature (post-hoc tamper)
3544
+ // 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
3545
+ // 'unsigned-substitution'— "unsigned" sidecar on a host with private key
3546
+ // (substitution attack signal)
3547
+ // 'explicitly-unsigned' — legitimately-unsigned attestation
3548
+ // 'no-sidecar' — sidecar file absent
3549
+ // 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
3550
+ sidecar_verify_class: classifySidecarVerify(verify),
2882
3551
  force_replay: !!args["force-replay"],
2883
3552
  }, pretty);
2884
3553
  }
2885
3554
 
3555
+ /**
3556
+ * Audit AA P1-1: map a verifyAttestationSidecar() result to a one-token
3557
+ * classification label. The label is persisted alongside the full
3558
+ * sidecar_verify object so auditors can filter override events by class
3559
+ * without regexing the human-readable reason string.
3560
+ */
3561
+ function classifySidecarVerify(verify) {
3562
+ if (!verify || typeof verify !== "object") return "unknown";
3563
+ if (verify.signed && verify.verified) return "verified";
3564
+ if (verify.signed && !verify.verified) return "tampered";
3565
+ if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
3566
+ if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
3567
+ if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
3568
+ if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
3569
+ if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
3570
+ return "unknown";
3571
+ }
3572
+
2886
3573
  /**
2887
3574
  * `exceptd attest <subverb> <session-id>` — auditor-facing operations on
2888
3575
  * persisted attestations. Subverbs:
@@ -2908,6 +3595,16 @@ function cmdAttest(runner, args, runOpts, pretty) {
2908
3595
  if (!sessionId) {
2909
3596
  return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
2910
3597
  }
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.
3604
+ try { validateSessionIdForRead(sessionId); }
3605
+ catch (e) {
3606
+ return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
3607
+ }
2911
3608
  const dir = findSessionDir(sessionId, runOpts);
2912
3609
  if (!dir) {
2913
3610
  return emitError(`attest ${subverb}: no session dir for ${sessionId}. Searched: ${resolveAttestationRoot(runOpts)} + .exceptd/attestations/`, { session_id: sessionId }, pretty);
@@ -2979,13 +3676,64 @@ function cmdAttest(runner, args, runOpts, pretty) {
2979
3676
  const crypto = require("crypto");
2980
3677
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2981
3678
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
3679
+ // Same pin cross-check as verifyAttestationSidecar().
3680
+ // The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
3681
+ // public-key load site was not honored here — `attest verify` loaded
3682
+ // keys/public.pem raw. Refuse to verify any sidecar when the local
3683
+ // public.pem diverges from the pinned fingerprint (unless KEYS_ROTATED=1).
3684
+ const pinError = pubKey ? assertExpectedFingerprint(pubKey) : null;
3685
+ if (pinError) {
3686
+ return emitError(
3687
+ `attest verify: ${pinError}`,
3688
+ { verb: "attest verify", session_id: sessionId, pin_error: pinError },
3689
+ pretty
3690
+ );
3691
+ }
3692
+ // Audit AA P1-1: on the verifying host, detect "unsigned" sidecar
3693
+ // substitution by checking whether .keys/private.pem is present. A
3694
+ // legitimately-unsigned attestation cannot coexist with a private key on
3695
+ // the same host — that combination is sidecar substitution (attacker
3696
+ // tampered attestation.json and overwrote .sig with the unsigned stub).
3697
+ const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3698
+ const hasPrivKey = fs.existsSync(privKeyPath);
2982
3699
  const results = files.map(f => {
2983
3700
  const sigPath = path.join(dir, f + ".sig");
2984
3701
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
2985
- const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
2986
- if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3702
+ // Audit AA P1-2: wrap JSON.parse so a corrupt sidecar surfaces as a
3703
+ // structured tamper-class result (signed:false, verified:false,
3704
+ // tamper_class:"sidecar-corrupt") rather than throwing into the outer
3705
+ // dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
3706
+ // generic exit-1 with no `results` array — operators piping through
3707
+ // `set -e` saw "command failed" with no tamper signal.
3708
+ let sigDoc;
3709
+ try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
3710
+ catch (e) {
3711
+ return {
3712
+ file: f,
3713
+ signed: false,
3714
+ verified: false,
3715
+ reason: `sidecar parse error: ${e.message}`,
3716
+ tamper_class: "sidecar-corrupt",
3717
+ };
3718
+ }
3719
+ if (sigDoc.algorithm === "unsigned") {
3720
+ // Audit AA P1-1: substitution detection.
3721
+ if (hasPrivKey) {
3722
+ return {
3723
+ file: f,
3724
+ signed: false,
3725
+ verified: false,
3726
+ 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)",
3727
+ tamper_class: "unsigned-substitution",
3728
+ };
3729
+ }
3730
+ return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3731
+ }
2987
3732
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
2988
- const content = fs.readFileSync(path.join(dir, f), "utf8");
3733
+ // C: normalize before crypto.verify mirrors the signer
3734
+ // path so the verify pair is byte-stable across CRLF / BOM churn.
3735
+ const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
3736
+ const content = normalizeAttestationBytes(rawContent);
2989
3737
  try {
2990
3738
  const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
2991
3739
  key: pubKey, dsaEncoding: "ieee-p1363",
@@ -2995,7 +3743,31 @@ function cmdAttest(runner, args, runOpts, pretty) {
2995
3743
  return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
2996
3744
  }
2997
3745
  });
2998
- emit({ verb: "attest verify", session_id: sessionId, results }, pretty);
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).
3753
+ //
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.
3759
+ const tampered = results.some(r =>
3760
+ (r.signed && !r.verified)
3761
+ || r.tamper_class === "sidecar-corrupt"
3762
+ || r.tamper_class === "unsigned-substitution"
3763
+ );
3764
+ const body = { verb: "attest verify", session_id: sessionId, results };
3765
+ if (tampered) {
3766
+ body.ok = false;
3767
+ body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
3768
+ process.exitCode = 6;
3769
+ }
3770
+ emit(body, pretty);
2999
3771
  return;
3000
3772
  }
3001
3773
 
@@ -3776,7 +4548,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
3776
4548
  }
3777
4549
 
3778
4550
  function cmdListAttestations(runner, args, runOpts, pretty) {
3779
- // v0.12.14 (audit A P2-3): --playbook is registered as `multi:` so
4551
+ // v0.12.14: --playbook is registered as `multi:` so
3780
4552
  // `--playbook a --playbook b` lands as an array. The prior filter used
3781
4553
  // strict equality (`j.playbook_id !== args.playbook`) — always false for
3782
4554
  // array, silently producing count: 0. Normalize to a Set up-front.
@@ -3785,16 +4557,14 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
3785
4557
  const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
3786
4558
  return new Set(list.filter(x => typeof x === "string" && x.length > 0));
3787
4559
  })();
3788
- // v0.12.14 (audit A P2-6): --since must be a parseable ISO-8601 timestamp.
4560
+ // v0.12.14: --since must be a parseable ISO-8601 timestamp.
3789
4561
  // Prior behavior silently accepted any string and lexically compared to
3790
4562
  // captured_at, producing 0-result or full-result depending on the string.
3791
4563
  if (args.since != null) {
3792
- if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
3793
- return emitError(
3794
- `attest list: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
3795
- null, pretty
3796
- );
3797
- }
4564
+ // R-F10: regex BEFORE Date.parse bare integers like "99" would
4565
+ // otherwise parse as the year 1999 and silently filter wrong eras.
4566
+ const sinceErr = validateIsoSince(args.since);
4567
+ if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
3798
4568
  }
3799
4569
  // Enumerate sessions across both v0.11.0 default root and legacy cwd-
3800
4570
  // relative root, so operators with prior attestations still see them.
@@ -3938,7 +4708,10 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3938
4708
  if (args.evidence) {
3939
4709
  try { payload = readEvidence(args.evidence); }
3940
4710
  catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
3941
- } else if (!process.stdin.isTTY) {
4711
+ } else if (hasReadableStdin()) {
4712
+ // EE P1-7: hasReadableStdin() probes via fstat before falling into
4713
+ // readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
4714
+ // size===0) would otherwise hang here.
3942
4715
  // Drain stdin for any evidence event.
3943
4716
  try {
3944
4717
  const buf = fs.readFileSync(0, "utf8");
@@ -3979,7 +4752,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3979
4752
  process.exitCode = 1;
3980
4753
  return;
3981
4754
  }
3982
- // v0.12.14 (audit A P2-1): ai-run --no-stream previously emitted a
4755
+ // v0.12.14: ai-run --no-stream previously emitted a
3983
4756
  // session_id but never persisted the attestation, so the AI agent
3984
4757
  // calling ai-run couldn't chain into `attest show / verify / diff`
3985
4758
  // or `reattest` with the returned id. Now: same persistAttestation
@@ -4085,7 +4858,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4085
4858
  writeLine({ phase: "analyze", ...result.phases?.analyze });
4086
4859
  writeLine({ phase: "validate", ...result.phases?.validate });
4087
4860
  writeLine({ phase: "close", ...result.phases?.close });
4088
- // v0.12.14 (audit A P2-1): persist the attestation in streaming mode
4861
+ // v0.12.14: persist the attestation in streaming mode
4089
4862
  // too. Without this, the session_id emitted in the `done` frame
4090
4863
  // can't be resolved by `attest show / verify / diff` or `reattest`.
4091
4864
  if (result.session_id) {
@@ -4632,4 +5405,4 @@ function cmdCi(runner, args, runOpts, pretty) {
4632
5405
 
4633
5406
  if (require.main === module) main();
4634
5407
 
4635
- module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
5408
+ module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS, persistAttestation };