@blamejs/exceptd-skills 0.12.20 → 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 (51) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/bin/exceptd.js +504 -41
  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 +20 -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 +395 -69
  28. package/lib/prefetch.js +3 -3
  29. package/lib/refresh-external.js +13 -2
  30. package/lib/refresh-network.js +13 -13
  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-indexes.js +2 -2
  35. package/lib/verify.js +28 -9
  36. package/manifest.json +47 -47
  37. package/package.json +1 -1
  38. package/sbom.cdx.json +6 -6
  39. package/scripts/check-manifest-snapshot.js +1 -1
  40. package/scripts/check-sbom-currency.js +1 -1
  41. package/scripts/predeploy.js +6 -6
  42. package/scripts/refresh-manifest-snapshot.js +2 -2
  43. package/scripts/validate-vendor-online.js +1 -1
  44. package/scripts/verify-shipped-tarball.js +9 -10
  45. package/skills/compliance-theater/skill.md +4 -1
  46. package/skills/exploit-scoring/skill.md +20 -1
  47. package/skills/framework-gap-analysis/skill.md +6 -2
  48. package/skills/kernel-lpe-triage/skill.md +50 -3
  49. package/skills/threat-model-currency/skill.md +6 -4
  50. package/skills/webapp-security/skill.md +1 -1
  51. 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
@@ -607,7 +607,7 @@ function emit(obj, pretty, humanRenderer) {
607
607
  }
608
608
 
609
609
  function emitError(msg, extra, pretty) {
610
- // 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
611
611
  // to defend stdout-buffered writes from truncation under piped consumers.
612
612
  // emitError() (stderr) kept process.exit(1), which has the same truncation
613
613
  // class — CLAUDE.md's "fix the class, not the instance." Now: write to
@@ -619,6 +619,50 @@ function emitError(msg, extra, pretty) {
619
619
  process.exitCode = 1;
620
620
  }
621
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
+
622
666
  function readEvidence(evidenceFlag) {
623
667
  if (!evidenceFlag) return {};
624
668
  if (evidenceFlag === "-") {
@@ -637,13 +681,67 @@ function readEvidence(evidenceFlag) {
637
681
  if (stat.size > MAX_EVIDENCE_BYTES) {
638
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.`);
639
683
  }
640
- 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);
641
688
  }
642
689
 
643
690
  function loadRunner() {
644
691
  return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
645
692
  }
646
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
+
647
745
  /**
648
746
  * R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
649
747
  * `attest list --since` and `reattest --since` accepted anything Date.parse
@@ -717,8 +815,17 @@ function detectVexShape(doc) {
717
815
  if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
718
816
  return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
719
817
  }
720
- if (doc.bomFormat === "CycloneDX" && !Array.isArray(doc.vulnerabilities)) {
721
- 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 };
722
829
  }
723
830
  if (Array.isArray(doc.statements) && !ctxStr) {
724
831
  return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
@@ -863,13 +970,137 @@ function dispatchPlaybook(cmd, argv) {
863
970
  pretty
864
971
  );
865
972
  }
866
- 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;
867
1077
  }
1078
+
868
1079
  // --ack: operator acknowledges the jurisdiction obligations surfaced by
869
1080
  // govern. Captured in attestation; downstream tooling can check whether
870
1081
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
871
1082
  // 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 };
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
+ }
873
1104
 
874
1105
  let runner;
875
1106
  try {
@@ -1209,7 +1440,7 @@ Flags:
1209
1440
  Stdin event grammar (one JSON object per line):
1210
1441
  {"event":"evidence","payload":{"observations":{},"verdict":{}}}
1211
1442
 
1212
- Stdin acceptance contract (Audit L F22):
1443
+ Stdin acceptance contract:
1213
1444
  In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
1214
1445
  parseable {"event":"evidence","payload":{...}} line. That line wins:
1215
1446
  subsequent evidence events on the same run are ignored (the handler
@@ -1643,7 +1874,7 @@ function cmdPlan(runner, args, runOpts, pretty) {
1643
1874
  emit(plan, pretty);
1644
1875
  }
1645
1876
 
1646
- // v0.12.15 (audit L F1, F2): --scope must validate against the accepted
1877
+ // v0.12.15: --scope must validate against the accepted
1647
1878
  // set. The prior shape silently returned [] for any unknown scope, which
1648
1879
  // in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
1649
1880
  // "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
@@ -1823,7 +2054,15 @@ function cmdRun(runner, args, runOpts, pretty) {
1823
2054
  // fired, making `echo '{...}' | exceptd run <pb>` silently behave like
1824
2055
  // no-evidence on Windows. cmdAiRun's path (below) already uses the
1825
2056
  // truthy form, so this brings cmdRun + cmdIngest to parity.
1826
- if (!args.evidence && !process.stdin.isTTY) {
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()) {
1827
2066
  args.evidence = "-";
1828
2067
  }
1829
2068
  if (args.evidence) {
@@ -1859,10 +2098,11 @@ function cmdRun(runner, args, runOpts, pretty) {
1859
2098
  if (args.vex) {
1860
2099
  let vexDoc;
1861
2100
  // 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.
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.
1866
2106
  const MAX_VEX_BYTES = 32 * 1024 * 1024;
1867
2107
  let vstat;
1868
2108
  try { vstat = fs.statSync(args.vex); }
@@ -1870,14 +2110,19 @@ function cmdRun(runner, args, runOpts, pretty) {
1870
2110
  return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
1871
2111
  }
1872
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).
1873
2115
  return emitError(
1874
- `run: --vex file too large: ${vstat.size} bytes > ${MAX_VEX_BYTES} byte limit. Reduce the document or split into multiple passes.`,
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.`,
1875
2117
  { provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
1876
2118
  pretty
1877
2119
  );
1878
2120
  }
1879
2121
  try {
1880
- 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);
1881
2126
  } catch (e) {
1882
2127
  return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
1883
2128
  }
@@ -1899,6 +2144,16 @@ function cmdRun(runner, args, runOpts, pretty) {
1899
2144
  const vexSet = runner.vexFilterFromDoc(vexDoc);
1900
2145
  submission.signals = submission.signals || {};
1901
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] : [];
1902
2157
  } catch (e) {
1903
2158
  return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
1904
2159
  }
@@ -1935,9 +2190,27 @@ function cmdRun(runner, args, runOpts, pretty) {
1935
2190
  // v0.11.10 (#119): add result.ack alias for consumers reading the
1936
2191
  // ack state by that name (`result.ack` is shorter + matches the CLI flag).
1937
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";
1938
2207
  if (result && runOpts.operator_consent) {
1939
2208
  result.operator_consent = runOpts.operator_consent;
1940
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
+ }
1941
2214
  } else if (result) {
1942
2215
  result.ack = false;
1943
2216
  }
@@ -1950,7 +2223,8 @@ function cmdRun(runner, args, runOpts, pretty) {
1950
2223
  directiveId: result.directive_id,
1951
2224
  evidenceHash: result.evidence_hash,
1952
2225
  operator: runOpts.operator,
1953
- operatorConsent: runOpts.operator_consent,
2226
+ // EE P1-6: gate consent persistence on classification=detected.
2227
+ operatorConsent: consentApplies ? runOpts.operator_consent : null,
1954
2228
  submission,
1955
2229
  runOpts,
1956
2230
  forceOverwrite: !!args["force-overwrite"],
@@ -1968,7 +2242,7 @@ function cmdRun(runner, args, runOpts, pretty) {
1968
2242
  hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
1969
2243
  verb: "run",
1970
2244
  };
1971
- // v0.12.14 (audit A P1-2): exitCode + return instead of process.exit
2245
+ // v0.12.14: exitCode + return instead of process.exit
1972
2246
  // so the stderr line drains under piped CI consumers.
1973
2247
  process.stderr.write(JSON.stringify(err) + "\n");
1974
2248
  process.exitCode = 3;
@@ -2390,6 +2664,34 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2390
2664
  if (!lst.isFile()) {
2391
2665
  return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2392
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
+ }
2393
2695
  try {
2394
2696
  bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
2395
2697
  } catch (e) {
@@ -2486,7 +2788,11 @@ function cmdIngest(runner, args, runOpts, pretty) {
2486
2788
  // strict `=== false` check failed and ingest silently treated the
2487
2789
  // routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
2488
2790
  // detects piped stdin") was a no-op on Windows pre-fix.
2489
- if (!args.evidence && !process.stdin.isTTY) {
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()) {
2490
2796
  args.evidence = "-";
2491
2797
  }
2492
2798
  if (args.evidence) {
@@ -2691,8 +2997,17 @@ function persistAttestation(args) {
2691
2997
  // matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
2692
2998
  // .lock file with bounded retry, PID-liveness check on contention,
2693
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.
2694
3009
  const lockPath = filePath + ".lock";
2695
- const MAX_RETRIES = 50;
3010
+ const MAX_RETRIES = 10;
2696
3011
  const STALE_LOCK_MS = 30_000;
2697
3012
  let acquired = false;
2698
3013
  for (let i = 0; i < MAX_RETRIES; i++) {
@@ -2729,10 +3044,16 @@ function persistAttestation(args) {
2729
3044
  }
2730
3045
  }
2731
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.
2732
3052
  return {
2733
3053
  ok: false,
2734
- error: `Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts.`,
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.`,
2735
3055
  existingPath: path.relative(process.cwd(), filePath),
3056
+ lock_contention: true,
2736
3057
  };
2737
3058
  }
2738
3059
  try {
@@ -2770,7 +3091,7 @@ function persistAttestation(args) {
2770
3091
  * from "the .sig file was deleted by an attacker."
2771
3092
  */
2772
3093
  /**
2773
- * Audit P P1-C: byte-stability normalize() for the attestation pipeline.
3094
+ * C: byte-stability normalize() for the attestation pipeline.
2774
3095
  * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
2775
3096
  * normalize() implementations in lib/sign.js, lib/verify.js,
2776
3097
  * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
@@ -2796,7 +3117,7 @@ function maybeSignAttestation(filePath) {
2796
3117
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
2797
3118
  // verifier doesn't trust.
2798
3119
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
2799
- // Audit P P1-C: normalize attestation bytes before sign — strip leading
3120
+ // C: normalize attestation bytes before sign — strip leading
2800
3121
  // UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
2801
3122
  // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
2802
3123
  // attestation file lives on disk under .exceptd/ and can pick up CRLF
@@ -2852,7 +3173,7 @@ function maybeSignAttestation(filePath) {
2852
3173
  * Returns null if neither has the session.
2853
3174
  */
2854
3175
  /**
2855
- * 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
2856
3177
  * site, not just writes. The write path (persistAttestation) was hardened
2857
3178
  * in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
2858
3179
  * accepted arbitrary strings and joined them into path.join(root, id) with
@@ -2871,7 +3192,7 @@ function validateSessionIdForRead(sessionId) {
2871
3192
  }
2872
3193
 
2873
3194
  function findSessionDir(sessionId, runOpts) {
2874
- // 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.
2875
3196
  try { validateSessionIdForRead(sessionId); }
2876
3197
  catch { return null; }
2877
3198
  const candidates = [
@@ -2947,7 +3268,7 @@ function verifyAttestationSidecar(attFile) {
2947
3268
  const sigPath = attFile + ".sig";
2948
3269
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
2949
3270
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
2950
- // Audit Q P1 + R F6: consult keys/EXPECTED_FINGERPRINT before honoring
3271
+ // Consult keys/EXPECTED_FINGERPRINT before honoring
2951
3272
  // the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
2952
3273
  // at every public-key load site," but reattest's signature verifier
2953
3274
  // loaded keys/public.pem without the pin cross-check. A coordinated
@@ -2965,8 +3286,39 @@ function verifyAttestationSidecar(attFile) {
2965
3286
  }
2966
3287
  let sigDoc;
2967
3288
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
2968
- 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
+ }
2969
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
+ }
2970
3322
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
2971
3323
  }
2972
3324
  if (!pubKey) {
@@ -2975,7 +3327,7 @@ function verifyAttestationSidecar(attFile) {
2975
3327
  let content;
2976
3328
  try {
2977
3329
  const raw = fs.readFileSync(attFile, "utf8");
2978
- // Audit P P1-C: apply the same normalize() used by the signer so the
3330
+ // C: apply the same normalize() used by the signer so the
2979
3331
  // verify path is byte-stable across CRLF / BOM churn (Windows checkout
2980
3332
  // with core.autocrlf=true, editor round-trips, git-attributes flips).
2981
3333
  content = normalizeAttestationBytes(raw);
@@ -3036,7 +3388,16 @@ function cmdReattest(runner, args, runOpts, pretty) {
3036
3388
  // tampering. `verified === false && signed === true` is the real tamper
3037
3389
  // signal.
3038
3390
  const verify = verifyAttestationSidecar(attFile);
3039
- 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"]) {
3040
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`);
3041
3402
  const body = {
3042
3403
  ok: false,
@@ -3051,10 +3412,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
3051
3412
  process.exitCode = 6;
3052
3413
  return;
3053
3414
  }
3054
- if (verify.signed && !verify.verified && args["force-replay"]) {
3415
+ if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
3055
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`);
3056
3417
  } 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
3418
+ // missing-sidecar is NOT benign. The previous flow accepted
3058
3419
  // a missing .sig file silently (only blocked on signed-but-invalid).
3059
3420
  // Sidecar deletion is observationally identical to sidecar tamper —
3060
3421
  // an attacker who can rewrite the attestation can also rm the sidecar,
@@ -3079,6 +3440,30 @@ function cmdReattest(runner, args, runOpts, pretty) {
3079
3440
  return;
3080
3441
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3081
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`);
3082
3467
  } else if (!verify.signed && verify.reason !== "no .sig sidecar") {
3083
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`);
3084
3469
  }
@@ -3151,10 +3536,40 @@ function cmdReattest(runner, args, runOpts, pretty) {
3151
3536
  // F10: persist the sidecar verify result + the force-replay flag so the
3152
3537
  // audit trail records whether the replay was authenticated input.
3153
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),
3154
3551
  force_replay: !!args["force-replay"],
3155
3552
  }, pretty);
3156
3553
  }
3157
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
+
3158
3573
  /**
3159
3574
  * `exceptd attest <subverb> <session-id>` — auditor-facing operations on
3160
3575
  * persisted attestations. Subverbs:
@@ -3261,7 +3676,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3261
3676
  const crypto = require("crypto");
3262
3677
  const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
3263
3678
  const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
3264
- // Audit Q P1 + R F6: same pin cross-check as verifyAttestationSidecar().
3679
+ // Same pin cross-check as verifyAttestationSidecar().
3265
3680
  // The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
3266
3681
  // public-key load site was not honored here — `attest verify` loaded
3267
3682
  // keys/public.pem raw. Refuse to verify any sidecar when the local
@@ -3274,13 +3689,48 @@ function cmdAttest(runner, args, runOpts, pretty) {
3274
3689
  pretty
3275
3690
  );
3276
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);
3277
3699
  const results = files.map(f => {
3278
3700
  const sigPath = path.join(dir, f + ".sig");
3279
3701
  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)" };
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
+ }
3282
3732
  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
3733
+ // C: normalize before crypto.verify — mirrors the signer
3284
3734
  // path so the verify pair is byte-stable across CRLF / BOM churn.
3285
3735
  const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
3286
3736
  const content = normalizeAttestationBytes(rawContent);
@@ -3300,7 +3750,17 @@ function cmdAttest(runner, args, runOpts, pretty) {
3300
3750
  // signal even when an attestation had been forged. emit()'s ok:false
3301
3751
  // → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
3302
3752
  // from generic failure, so explicitly raise to 6 (cmdReattest's code).
3303
- const tampered = results.some(r => r.signed && !r.verified);
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
+ );
3304
3764
  const body = { verb: "attest verify", session_id: sessionId, results };
3305
3765
  if (tampered) {
3306
3766
  body.ok = false;
@@ -4088,7 +4548,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4088
4548
  }
4089
4549
 
4090
4550
  function cmdListAttestations(runner, args, runOpts, pretty) {
4091
- // v0.12.14 (audit A P2-3): --playbook is registered as `multi:` so
4551
+ // v0.12.14: --playbook is registered as `multi:` so
4092
4552
  // `--playbook a --playbook b` lands as an array. The prior filter used
4093
4553
  // strict equality (`j.playbook_id !== args.playbook`) — always false for
4094
4554
  // array, silently producing count: 0. Normalize to a Set up-front.
@@ -4097,7 +4557,7 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4097
4557
  const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
4098
4558
  return new Set(list.filter(x => typeof x === "string" && x.length > 0));
4099
4559
  })();
4100
- // 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.
4101
4561
  // Prior behavior silently accepted any string and lexically compared to
4102
4562
  // captured_at, producing 0-result or full-result depending on the string.
4103
4563
  if (args.since != null) {
@@ -4248,7 +4708,10 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4248
4708
  if (args.evidence) {
4249
4709
  try { payload = readEvidence(args.evidence); }
4250
4710
  catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
4251
- } 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.
4252
4715
  // Drain stdin for any evidence event.
4253
4716
  try {
4254
4717
  const buf = fs.readFileSync(0, "utf8");
@@ -4289,7 +4752,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4289
4752
  process.exitCode = 1;
4290
4753
  return;
4291
4754
  }
4292
- // 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
4293
4756
  // session_id but never persisted the attestation, so the AI agent
4294
4757
  // calling ai-run couldn't chain into `attest show / verify / diff`
4295
4758
  // or `reattest` with the returned id. Now: same persistAttestation
@@ -4395,7 +4858,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4395
4858
  writeLine({ phase: "analyze", ...result.phases?.analyze });
4396
4859
  writeLine({ phase: "validate", ...result.phases?.validate });
4397
4860
  writeLine({ phase: "close", ...result.phases?.close });
4398
- // v0.12.14 (audit A P2-1): persist the attestation in streaming mode
4861
+ // v0.12.14: persist the attestation in streaming mode
4399
4862
  // too. Without this, the session_id emitted in the `done` frame
4400
4863
  // can't be resolved by `attest show / verify / diff` or `reattest`.
4401
4864
  if (result.session_id) {