@blamejs/exceptd-skills 0.12.22 → 0.12.24

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 (47) hide show
  1. package/AGENTS.md +18 -12
  2. package/ARCHITECTURE.md +2 -2
  3. package/CHANGELOG.md +152 -2
  4. package/CONTEXT.md +126 -69
  5. package/README.md +21 -8
  6. package/bin/exceptd.js +972 -464
  7. package/data/_indexes/_meta.json +3 -3
  8. package/data/_indexes/stale-content.json +10 -3
  9. package/data/playbooks/ai-api.json +1 -1
  10. package/data/playbooks/containers.json +1 -1
  11. package/data/playbooks/cred-stores.json +1 -1
  12. package/data/playbooks/crypto-codebase.json +1 -1
  13. package/data/playbooks/crypto.json +1 -1
  14. package/data/playbooks/framework.json +1 -1
  15. package/data/playbooks/hardening.json +1 -1
  16. package/data/playbooks/kernel.json +1 -1
  17. package/data/playbooks/library-author.json +1 -1
  18. package/data/playbooks/mcp.json +1 -1
  19. package/data/playbooks/runtime.json +1 -1
  20. package/data/playbooks/sbom.json +1 -1
  21. package/data/playbooks/secrets.json +39 -1
  22. package/lib/auto-discovery.js +28 -4
  23. package/lib/cross-ref-api.js +12 -11
  24. package/lib/cve-curation.js +18 -19
  25. package/lib/exit-codes.js +72 -0
  26. package/lib/flag-suggest.js +130 -0
  27. package/lib/id-validation.js +95 -0
  28. package/lib/lint-skills.js +73 -6
  29. package/lib/playbook-runner.js +617 -343
  30. package/lib/prefetch.js +134 -21
  31. package/lib/refresh-external.js +205 -26
  32. package/lib/refresh-network.js +64 -16
  33. package/lib/schemas/cve-catalog.schema.json +7 -1
  34. package/lib/schemas/playbook.schema.json +51 -0
  35. package/lib/scoring.js +49 -7
  36. package/lib/sign.js +10 -11
  37. package/lib/source-osv.js +7 -7
  38. package/lib/upstream-check-cli.js +16 -1
  39. package/lib/upstream-check.js +9 -0
  40. package/lib/validate-catalog-meta.js +1 -1
  41. package/lib/validate-cve-catalog.js +1 -1
  42. package/lib/verify.js +56 -30
  43. package/manifest.json +40 -40
  44. package/package.json +8 -2
  45. package/sbom.cdx.json +6 -6
  46. package/scripts/check-test-coverage.js +67 -0
  47. package/scripts/verify-shipped-tarball.js +27 -18
package/bin/exceptd.js CHANGED
@@ -58,6 +58,15 @@ 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
+ // Centralised exit-code constants + id validators + flag-typo suggester.
62
+ // Replacing the prior bare-numbers + inline-regex pattern with named
63
+ // constants so a new verb cannot regress the exit-code contract by typo,
64
+ // and so the help-text dump (`doctor --exit-codes`) and the runtime
65
+ // behavior share the same source of truth.
66
+ const { EXIT_CODES, listExitCodes } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
67
+ const { validateIdComponent } = require(path.join(PKG_ROOT, "lib", "id-validation.js"));
68
+ const { suggestFlag, flagsFor } = require(path.join(PKG_ROOT, "lib", "flag-suggest.js"));
69
+
61
70
  /**
62
71
  * Factor the EXPECTED_FINGERPRINT pin check used by
63
72
  * the attestation pipeline. Centralizes the policy (compute live SHA-256
@@ -85,14 +94,21 @@ function assertExpectedFingerprint(pubKeyPem) {
85
94
  } catch (e) {
86
95
  return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
87
96
  }
88
- // KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
89
- // pin file (Notepad with files.encoding=utf8bom) is tolerated identically
90
- // across every verify site. The helper strips leading U+FEFF + ignores
91
- // comment lines.
97
+ // Route through the shared lib/verify loader so a BOM-prefixed pin file
98
+ // (Notepad with files.encoding=utf8bom) is tolerated identically across
99
+ // every verify site. The helper strips leading U+FEFF + ignores comment
100
+ // lines.
92
101
  const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
93
102
  const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
94
103
  if (firstLine === liveFp) return null;
95
- if (process.env.KEYS_ROTATED === "1") return null;
104
+ if (process.env.KEYS_ROTATED === "1") {
105
+ process.emitWarning(
106
+ `EXPECTED_FINGERPRINT mismatch accepted via KEYS_ROTATED=1: live=${liveFp} pin=${firstLine}. ` +
107
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
108
+ { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
109
+ );
110
+ return null;
111
+ }
96
112
  return (
97
113
  `EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
98
114
  `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
@@ -268,9 +284,11 @@ v0.12.0 canonical surface
268
284
  --registry-check (v0.11.14) opt-in: query npm registry
269
285
  for latest published version + days behind
270
286
 
271
- ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
272
- 3 ran-but-no-evidence, 4 blocked (ok:false),
273
- 5 jurisdiction clock started, 1 framework error.
287
+ ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
288
+ 2 detected/escalate, 3 ran-but-no-evidence,
289
+ 4 blocked (ok:false), 5 jurisdiction clock started.
290
+ (Codes 6/7/8/9 surface on attest verify / run /
291
+ ai-run / ingest, not ci.)
274
292
  --all | --scope <type> | (auto-detect)
275
293
  --max-rwep <n> cap below playbook default
276
294
  --block-on-jurisdiction-clock
@@ -346,6 +364,8 @@ Examples:
346
364
  exceptd ci --scope code --max-rwep 70 # gate every code playbook
347
365
  exceptd ask "I think someone replaced npm packages" # natural-language route
348
366
 
367
+ Unknown verbs exit 2 with a structured ok:false body on stderr.
368
+
349
369
  Full documentation: ${PKG_ROOT}/README.md
350
370
  Project rules: ${PKG_ROOT}/AGENTS.md
351
371
  `);
@@ -495,11 +515,9 @@ function main() {
495
515
  if (typeof resolver !== "function") {
496
516
  // Emit a structured JSON error matching the seven-phase verbs so operators
497
517
  // piping through `jq` get one consistent shape across the CLI surface.
498
- // R-F8: pre-fix, the structured-JSON stderr write was followed by
499
- // process.exit(2) the v0.11.10 truncation class applied to stderr
500
- // just as it does to stdout. Route through emitError() (which uses
501
- // exitCode + return per v0.12.14) so the JSON drains, then promote
502
- // the exit code to 2 (unknown-command remains a distinct exit class).
518
+ // emitError() sets exitCode + returns rather than calling process.exit()
519
+ // so the stderr JSON drains before teardown; promote the exit code to 2
520
+ // afterwards (unknown-command remains a distinct exit class).
503
521
  emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
504
522
  process.exitCode = 2;
505
523
  return;
@@ -507,7 +525,7 @@ function main() {
507
525
 
508
526
  const script = resolver();
509
527
  if (!fs.existsSync(script)) {
510
- // R-F8: same class — emitError + exitCode rather than stderr + exit().
528
+ // emitError + exitCode rather than stderr + exit() so the JSON drains.
511
529
  emitError(
512
530
  `command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
513
531
  { verb: cmd }
@@ -521,7 +539,7 @@ function main() {
521
539
  const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
522
540
  const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
523
541
  if (res.error) {
524
- // R-F8: same class emitError + exitCode.
542
+ // emitError + exitCode rather than stderr + exit() so the JSON drains.
525
543
  emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
526
544
  process.exitCode = 2;
527
545
  return;
@@ -624,7 +642,7 @@ function emitError(msg, extra, pretty) {
624
642
  }
625
643
 
626
644
  /**
627
- * EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
645
+ * Shared BOM-tolerant JSON file reader. Windows tools commonly emit
628
646
  * UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
629
647
  * `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
630
648
  * becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
@@ -699,7 +717,7 @@ function readEvidence(evidenceFlag) {
699
717
  if (stat.size > MAX_EVIDENCE_BYTES) {
700
718
  throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
701
719
  }
702
- // EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
720
+ // Route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
703
721
  // Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
704
722
  // decode in readFileSync chokes on the leading 0xFEFF.
705
723
  return readJsonFile(evidenceFlag);
@@ -710,7 +728,7 @@ function loadRunner() {
710
728
  }
711
729
 
712
730
  /**
713
- * EE P1-7: detect whether stdin actually has data without blocking.
731
+ * Detect whether stdin actually has data without blocking.
714
732
  *
715
733
  * `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
716
734
  * `false`, `undefined`, OR `null`. Test harnesses with custom stdin
@@ -742,13 +760,12 @@ function hasReadableStdin() {
742
760
  let st;
743
761
  try { st = fs.fstatSync(0); }
744
762
  catch {
745
- // KK P1-4: fstat failed — tighten the Windows fallback to require
746
- // `isTTY === false` STRICTLY (not falsy). Pre-fix `!process.stdin.isTTY`
747
- // returned true when isTTY was undefined (Mocha/Jest test harness with
748
- // wrapped duplexer on Windows), so the caller called `fs.readFileSync(0)`
749
- // and blocked indefinitely waiting on an EOF that never came. The legacy
750
- // MSYS-bash piping scenario (R-F3 in v0.12.16) sets isTTY === false on
751
- // win32 when piped, so the strict check preserves that working case.
763
+ // fstat failed — on Windows require `isTTY === false` STRICTLY (not
764
+ // falsy). A non-strict check returns true when isTTY is undefined (e.g.
765
+ // Mocha/Jest test harnesses with a wrapped duplexer on Windows), which
766
+ // causes fs.readFileSync(0) to block indefinitely waiting on an EOF
767
+ // that never arrives. MSYS-bash piping on win32 sets isTTY === false,
768
+ // so the strict check still admits genuine piped input.
752
769
  if (process.platform === "win32") return process.stdin.isTTY === false;
753
770
  return false;
754
771
  }
@@ -778,13 +795,11 @@ function hasReadableStdin() {
778
795
  }
779
796
 
780
797
  /**
781
- * R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
782
- * `attest list --since` and `reattest --since` accepted anything Date.parse
783
- * could chew on including bare integers like "99", which JavaScript
784
- * happily resolves to 1999-12-01T00:00:00Z (Y2K-era millisecond / two-digit
785
- * year heuristic). Operators got a "valid timestamp" check that silently
786
- * filtered the wrong years. Now: require an explicit calendar-date shape
787
- * (YYYY-MM-DD with optional time component) BEFORE handing to Date.parse.
798
+ * ISO-8601 shape regex applied BEFORE Date.parse for --since flags. Without
799
+ * the regex check, bare integers like "99" coerce through Date.parse to
800
+ * 1999-12-01T00:00:00Z (two-digit-year heuristic), silently filtering the
801
+ * wrong years. Requires an explicit calendar-date shape (YYYY-MM-DD with
802
+ * optional time component) before handing to Date.parse.
788
803
  *
789
804
  * Returns null on success; returns the human-facing error message string
790
805
  * on failure so the caller can wrap it with its own verb prefix.
@@ -798,7 +813,7 @@ function validateIsoSince(raw) {
798
813
  }
799
814
 
800
815
  /**
801
- * F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
816
+ * Detect whether a parsed JSON document is plausibly CycloneDX VEX or
802
817
  * OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
803
818
  * anything else, which means an operator who passes SARIF / SBOM / CSAF /
804
819
  * advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
@@ -820,12 +835,12 @@ function detectVexShape(doc) {
820
835
  const isBom = doc.bomFormat === "CycloneDX";
821
836
  const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
822
837
  const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
823
- // R-F4: empty vulnerabilities arrays cannot vouch for CycloneDX shape
824
- // on their own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}`
825
- // previously passed because `length === 0` always satisfied
838
+ // Empty vulnerabilities arrays cannot vouch for CycloneDX shape on their
839
+ // own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}` would
840
+ // otherwise pass because `length === 0` trivially satisfies
826
841
  // `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
827
- // specVersion) when the array is empty; non-empty arrays still pass
828
- // when any entry has vex-shaped fields (id / bom-ref / analysis).
842
+ // specVersion) when the array is empty; non-empty arrays still pass when
843
+ // any entry has vex-shaped fields (id / bom-ref / analysis).
829
844
  if (doc.vulnerabilities.length === 0) {
830
845
  if (hasCyclonedxMarker) {
831
846
  return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
@@ -850,8 +865,8 @@ function detectVexShape(doc) {
850
865
  if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
851
866
  return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
852
867
  }
853
- // EE P1-1: a CycloneDX SBOM with no `vulnerabilities` key is a legitimate
854
- // "0-CVE VEX filter" submission — the operator is asserting nothing here is
868
+ // A CycloneDX SBOM with no `vulnerabilities` key is a legitimate "0-CVE
869
+ // VEX filter" submission — the operator is asserting nothing here is
855
870
  // exploitable. Accept it as cyclonedx-vex with an empty filter set (the
856
871
  // runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
857
872
  // for documents that carry a CycloneDX-flavored specVersion ("1.x") without
@@ -869,6 +884,10 @@ function detectVexShape(doc) {
869
884
  }
870
885
 
871
886
  function firstDirectiveId(runner, playbookId) {
887
+ // Defense-in-depth: callers that touch this helper directly (test
888
+ // harnesses, library consumers) still get path-traversal refusal.
889
+ const r = validateIdComponent(playbookId, "playbook");
890
+ if (!r.ok) throw new Error(`invalid playbook id (${r.reason}): ${typeof playbookId === "string" ? playbookId.slice(0, 80) : typeof playbookId}`);
872
891
  const pb = runner.loadPlaybook(playbookId);
873
892
  if (!pb.directives || !pb.directives.length) {
874
893
  throw new Error(`Playbook ${playbookId} has no directives.`);
@@ -894,7 +913,9 @@ function dispatchPlaybook(cmd, argv) {
894
913
  // gate alongside --signatures. doctor --registry-check + --signatures
895
914
  // were already accepted; explicit registration removes the silent
896
915
  // "unknown bool flag" surface in parseArgs.
897
- "shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs"],
916
+ "shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs",
917
+ // doctor --exit-codes dumps the canonical exit-code table.
918
+ "exit-codes"],
898
919
  multi: ["playbook", "format"],
899
920
  });
900
921
  // v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
@@ -911,19 +932,111 @@ function dispatchPlaybook(cmd, argv) {
911
932
  // Hoist into module-level state so emit() can read it without plumbing.
912
933
  global.__exceptdWantJson = args._jsonMode;
913
934
  const pretty = !!args.pretty;
935
+
936
+ // Flag-typo defense: anything supplied by the operator that isn't on the
937
+ // verb's allowlist gets a Levenshtein suggestion + immediate refusal.
938
+ // Pre-fix, `exceptd run --evidnce ev.json` silently absorbed --evidnce as
939
+ // a boolean flag and produced a cryptic downstream error when the runner
940
+ // got no evidence. Now: refuse at the dispatcher with the suggested
941
+ // correct flag so operators see the typo before any side effects run.
942
+ //
943
+ // Ignore parser-internal scratch keys (`_jsonMode`, leading-underscore) +
944
+ // the bare-positional bucket (`_`). REQUIRES_VALUE catches the
945
+ // value-bearing flags that parsed as boolean true (i.e. the operator
946
+ // forgot the value).
947
+ // Value-bearing flags only. Boolean flags (--ack, --latest, --force-replay,
948
+ // --force-stale, --ci, --pretty, etc.) are intentionally absent because
949
+ // their `true` parse is the canonical operator intent.
950
+ const REQUIRES_VALUE = new Set([
951
+ "evidence", "evidence-dir", "session-id", "operator", "csaf-status",
952
+ "publisher-namespace", "mode", "scope", "playbook", "phase", "tlp",
953
+ "against", "since", "bundle-epoch", "attestation-root", "format",
954
+ ]);
955
+ const verbAllowlist = flagsFor(cmd);
956
+ const allowlistSet = new Set(verbAllowlist);
957
+ // Internal-passthrough flags used by the parser / dispatcher that aren't
958
+ // in the operator-facing allowlist but must not trigger the typo check.
959
+ // The allowlist in lib/flag-suggest.js is operator-facing-only — these
960
+ // are the legacy/internal escape hatches that still need to flow
961
+ // through without a refusal.
962
+ const PASSTHROUGH_FLAGS = new Set([
963
+ "directive", "domain", "phase", "signal-list", "explain",
964
+ "signatures", "currency", "cves", "rfcs", "shipped-tarball",
965
+ "human", "json-stdout-only", "max-rwep", "diff-from-latest",
966
+ "upstream-check", "latest", "force-replay", "flat", "directives",
967
+ "fix", "session-key", "all", "scope", "playbook",
968
+ ]);
969
+ for (const key of Object.keys(args)) {
970
+ if (key === "_" || key.startsWith("_")) continue;
971
+ // Per-verb help is universal even when not in the allowlist.
972
+ if (key === "help" || key === "h") continue;
973
+ if (PASSTHROUGH_FLAGS.has(key)) {
974
+ if (REQUIRES_VALUE.has(key) && args[key] === true) {
975
+ return emitError(
976
+ `${cmd}: --${key} requires a value.`,
977
+ { verb: cmd, flag: key },
978
+ pretty
979
+ );
980
+ }
981
+ continue;
982
+ }
983
+ if (allowlistSet.has(key)) {
984
+ if (REQUIRES_VALUE.has(key) && args[key] === true) {
985
+ return emitError(
986
+ `${cmd}: --${key} requires a value.`,
987
+ { verb: cmd, flag: key },
988
+ pretty
989
+ );
990
+ }
991
+ continue;
992
+ }
993
+ // Refuse only when a close suggestion exists (likely typo). Unknown
994
+ // flags with no near-match fall through to verb-level handling so a
995
+ // future addition doesn't require an allowlist edit in this file
996
+ // before it can ship. The PASSTHROUGH_FLAGS list above plus the
997
+ // per-verb allowlist in lib/flag-suggest.js together cover every
998
+ // shipped flag; anything that misses both AND has a typo suggestion
999
+ // is the case operators benefit from refusing.
1000
+ const suggestion = suggestFlag(key, verbAllowlist);
1001
+ if (suggestion) {
1002
+ return emitError(
1003
+ `unknown flag --${key}`,
1004
+ { verb: cmd, suggested: suggestion },
1005
+ pretty
1006
+ );
1007
+ }
1008
+ }
914
1009
  const runOpts = {
915
- airGap: !!args["air-gap"],
1010
+ // Air-gap can be requested via the explicit flag OR the
1011
+ // EXCEPTD_AIR_GAP=1 environment variable. The env-var path is for
1012
+ // operators who export it once at shell-init time so every subsequent
1013
+ // invocation inherits the disposition without remembering the flag.
1014
+ airGap: !!args["air-gap"] || process.env.EXCEPTD_AIR_GAP === "1",
916
1015
  forceStale: !!args["force-stale"],
917
1016
  };
1017
+ // Air-gap advisory (one-time per process). Routed to stderr so JSON
1018
+ // consumers on stdout don't see it. The exceptd CLI does not perform
1019
+ // network egress in air-gap mode, but a host AI driving exceptd may
1020
+ // still call its model API — surface the boundary so operators verify
1021
+ // their agent runtime is offline too.
1022
+ if (runOpts.airGap && !process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN) {
1023
+ process.stderr.write(
1024
+ `[exceptd] air-gap: exceptd will not perform network egress. Your AI agent may still call its model API; verify your agent runtime is also offline.\n`
1025
+ );
1026
+ process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN = "1";
1027
+ }
918
1028
  if (args["session-id"]) {
919
- // v0.12.12: --session-id is a filesystem path component (resolves to
1029
+ // --session-id is a filesystem path component (resolves to
920
1030
  // .exceptd/attestations/<id>/attestation.json). Operator-supplied input
921
- // with `..` or path separators escapes the attestation root. Validate
922
- // strict allowlist before propagating.
1031
+ // with `..` or path separators escapes the attestation root. Route
1032
+ // through the shared validateIdComponent('session') helper so the regex
1033
+ // + all-dots refusal stay aligned with persistAttestation /
1034
+ // validateSessionIdForRead.
923
1035
  const sid = args["session-id"];
924
- if (typeof sid !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(sid)) {
1036
+ const r = validateIdComponent(sid, "session");
1037
+ if (!r.ok) {
925
1038
  return emitError(
926
- "run: --session-id must match /^[A-Za-z0-9._-]{1,64}$/ (alphanumeric, dot, underscore, hyphen; up to 64 chars). Path separators and '..' are rejected.",
1039
+ `run: --session-id ${r.reason}. Path separators and '..' are rejected.`,
927
1040
  { provided: typeof sid === "string" ? sid.slice(0, 80) : typeof sid },
928
1041
  pretty
929
1042
  );
@@ -939,13 +1052,28 @@ function dispatchPlaybook(cmd, argv) {
939
1052
  if (typeof ar !== "string" || ar.length === 0) {
940
1053
  return emitError("run: --attestation-root must be a non-empty string.", { provided: typeof ar }, pretty);
941
1054
  }
942
- if (ar.split(/[\\/]/).some(seg => seg === "..")) {
1055
+ const arSegments = ar.split(/[\\/]/);
1056
+ if (arSegments.some(seg => seg === "..")) {
943
1057
  return emitError(
944
1058
  "run: --attestation-root must not contain '..' path segments. Pass an absolute path under your home directory or an explicit project-relative path without traversal.",
945
1059
  { provided: ar.slice(0, 200) },
946
1060
  pretty
947
1061
  );
948
1062
  }
1063
+ // All-dots segments (`.`, `..`, `...`, etc.) all resolve into or above
1064
+ // the intended parent directory, defeating the attestation-root
1065
+ // confinement check. Refuse any non-empty segment that is entirely dots
1066
+ // — the leading-`.` empty segment of an absolute POSIX path is allowed,
1067
+ // and a single `.` mid-path means "this dir" but is collapsed by
1068
+ // path.resolve anyway; explicit refusal is cheaper than reasoning about
1069
+ // every collapsed-equivalent shape.
1070
+ if (arSegments.some(seg => seg.length > 0 && /^\.+$/.test(seg))) {
1071
+ return emitError(
1072
+ "run: --attestation-root path segment cannot consist entirely of dots (rejected: '.', '..', '...', etc.). Pass an absolute path or a project-relative path without traversal.",
1073
+ { provided: ar.slice(0, 200) },
1074
+ pretty
1075
+ );
1076
+ }
949
1077
  runOpts.attestationRoot = path.resolve(ar);
950
1078
  }
951
1079
  if (args["session-key"]) {
@@ -973,12 +1101,12 @@ function dispatchPlaybook(cmd, argv) {
973
1101
  // service identity. --operator <name> persists into the attestation file
974
1102
  // for audit-trail accountability.
975
1103
  //
976
- // F9: validate the input. Pre-fix the value flowed into runOpts unchanged,
977
- // so an operator could inject newlines / control chars / arbitrary length
978
- // into attestation export output (multi-line "operator:" key/value pairs
979
- // are a forgery surface — a forged second line could look like a separate
980
- // attestation field to a naive parser). Now: strip ASCII control chars
981
- // (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
1104
+ // Validate the input. Without this, a value flows into runOpts unchanged
1105
+ // and an operator could inject newlines / control chars / arbitrary
1106
+ // length into attestation export output (multi-line "operator:" key/value
1107
+ // pairs are a forgery surface — a forged second line could look like a
1108
+ // separate attestation field to a naive parser). Strip ASCII control
1109
+ // chars (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
982
1110
  if (args.operator !== undefined) {
983
1111
  if (typeof args.operator !== "string") {
984
1112
  return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
@@ -1005,8 +1133,8 @@ function dispatchPlaybook(cmd, argv) {
1005
1133
  pretty
1006
1134
  );
1007
1135
  }
1008
- // EE P1-3: the ASCII-only control-char regex above misses Unicode
1009
- // categories Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
1136
+ // The ASCII-only control-char regex above misses Unicode categories
1137
+ // Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
1010
1138
  // zero-width joiners (U+200B-D), invisible format chars, private-use
1011
1139
  // codepoints, unassigned codepoints. An operator string like
1012
1140
  // "alice‮evilbob" renders as "alicebobevila" in any UI that respects
@@ -1055,15 +1183,15 @@ function dispatchPlaybook(cmd, argv) {
1055
1183
  runOpts.operator = normalized;
1056
1184
  }
1057
1185
 
1058
- // NN P1-1 / P1-2 / P1-5: --csaf-status and --publisher-namespace shape the
1059
- // CSAF bundle emitted by phases 5-7. Verbs that don't drive those phases
1060
- // (brief, plan, govern, direct, look, attest, list-attestations, discover,
1061
- // doctor, lint, ask, verify-attestation, reattest) never assemble a
1062
- // bundle, so silently consuming these flags is a UX trap. Refuse on those
1063
- // verbs so the operator knows the flag was discarded — same pattern as
1064
- // EE P1-6 closed for --ack. Error message templates and emitError prefixes
1065
- // use the in-scope `cmd` verb so a brief invocation says "brief:" rather
1066
- // than misattributing the flag to run.
1186
+ // --csaf-status and --publisher-namespace shape the CSAF bundle emitted by
1187
+ // phases 5-7. Verbs that don't drive those phases (brief, plan, govern,
1188
+ // direct, look, attest, list-attestations, discover, doctor, lint, ask,
1189
+ // verify-attestation, reattest) never assemble a bundle, so silently
1190
+ // consuming these flags is a UX trap. Refuse on those verbs so the
1191
+ // operator knows the flag was discarded — same pattern as --ack. Error
1192
+ // message templates and emitError prefixes use the in-scope `cmd` verb so
1193
+ // a brief invocation says "brief:" rather than misattributing the flag
1194
+ // to run.
1067
1195
  const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
1068
1196
  "run", "ci", "run-all", "ai-run", "ingest",
1069
1197
  ]);
@@ -1143,13 +1271,13 @@ function dispatchPlaybook(cmd, argv) {
1143
1271
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
1144
1272
  // and wait for ack — this is how the ack gets recorded.
1145
1273
  //
1146
- // EE P1-6: --ack only makes sense on verbs that drive phases 5-7 (run /
1147
- // ingest / ai-run / ci / run-all / reattest). Info-only verbs (brief,
1148
- // plan, govern, direct, look, attest, list-attestations, discover,
1149
- // doctor, lint, ask, verify-attestation) never consume an attestation
1150
- // clock — accepting --ack silently here was a UX trap where operators
1151
- // believed they had recorded consent. Refuse on those verbs so the
1152
- // operator knows the flag is irrelevant.
1274
+ // --ack only makes sense on verbs that drive phases 5-7 (run / ingest /
1275
+ // ai-run / ci / run-all / reattest). Info-only verbs (brief, plan,
1276
+ // govern, direct, look, attest, list-attestations, discover, doctor,
1277
+ // lint, ask, verify-attestation) never consume an attestation clock —
1278
+ // accepting --ack silently is a UX trap where operators believe they have
1279
+ // recorded consent. Refuse on those verbs so the operator knows the flag
1280
+ // is irrelevant.
1153
1281
  const ACK_RELEVANT_VERBS = new Set([
1154
1282
  "run", "ingest", "ai-run", "ci", "run-all", "reattest",
1155
1283
  ]);
@@ -1206,7 +1334,15 @@ function dispatchPlaybook(cmd, argv) {
1206
1334
  return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
1207
1335
  }
1208
1336
  }
1209
- emitError(e.message, { verb: cmd }, pretty);
1337
+ // Wrap bare e.message so operators see the verb that triggered the
1338
+ // failure + the next action they can take. Re-running with --pretty
1339
+ // expands the cause for log-scraping; the GitHub-issues pointer lets
1340
+ // operators report reproducible-but-unhandled exceptions.
1341
+ emitError(
1342
+ `${cmd}: internal error (${e && e.message ? e.message : String(e)}). Re-run with --pretty for context; file at https://github.com/blamejs/exceptd-skills/issues if reproducible.`,
1343
+ { verb: cmd },
1344
+ pretty
1345
+ );
1210
1346
  }
1211
1347
  }
1212
1348
 
@@ -1413,19 +1549,50 @@ Exit codes (per-verb, post-run):
1413
1549
  3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
1414
1550
  4 Blocked Result returned ok:false (preflight halt).
1415
1551
  5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
1552
+ 6 TAMPERED Surfaced by attest verify; sidecar verification failed.
1553
+ 7 SESSION_ID_COLLISION run --session-id duplicate without --force-overwrite.
1416
1554
  8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
1417
1555
  attestation lock after the bounded retry budget
1418
1556
  (~1-2s). Distinct from 1 so callers can retry the
1419
1557
  operation rather than treat it as a hard failure.
1420
1558
  Surfaces as body.lock_contention=true,
1421
- body.exit_code=8.`,
1559
+ body.exit_code=8.
1560
+ 9 STORAGE_EXHAUSTED Attestation write hit ENOSPC / EDQUOT / EROFS.
1561
+
1562
+ Other operator-facing flags (full list in source; surfaced here for grep):
1563
+ --vex <file> CycloneDX / OpenVEX disposition filter.
1564
+ --evidence-dir <dir> Per-playbook submission files.
1565
+ --attestation-root <p> Override .exceptd/ root for this run.
1566
+ --mode <m> Investigation mode (self_service | authorized_pentest
1567
+ | ir_response | ctf | research | compliance_audit).`,
1422
1568
  ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
1423
1569
 
1424
1570
  Flags:
1425
1571
  --domain <id> Playbook ID (overrides submission.playbook_id).
1426
1572
  --directive <id> Directive ID (overrides submission.directive_id).
1427
1573
  --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
1428
- --pretty Indented JSON output.`,
1574
+ --session-id <id> Reuse a specific session id (must satisfy
1575
+ /^[A-Za-z0-9._-]{1,64}$/).
1576
+ --force-overwrite Override session-id collision refusal.
1577
+ --operator <name> Bind attestation to a specific identity.
1578
+ --ack Explicit operator consent for jurisdiction clock.
1579
+ --attestation-root <p> Override .exceptd/ root for this ingest.
1580
+ --mode <m> Investigation mode (self_service | authorized_pentest
1581
+ | ir_response | ctf | research | compliance_audit).
1582
+ --air-gap Honor air_gap_alternative paths.
1583
+ --force-stale Override threat_currency_score<50 gate.
1584
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1585
+ bundle. One of: draft | interim (default) | final.
1586
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1587
+ set this only after operator review of the advisory.
1588
+ --publisher-namespace <url>
1589
+ CSAF document.publisher.namespace (§3.1.7.4). The
1590
+ operator's organisation URL, NOT the tooling vendor.
1591
+ Must be an http://… or https://… URL, ≤256 chars.
1592
+ --pretty Indented JSON output.
1593
+
1594
+ Exit codes: 0 PASS, 1 framework, 4 blocked, 7 SESSION_ID_COLLISION,
1595
+ 8 LOCK_CONTENTION, 9 STORAGE_EXHAUSTED.`,
1429
1596
  reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
1430
1597
 
1431
1598
  Args / flags:
@@ -1435,7 +1602,12 @@ Args / flags:
1435
1602
  --since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
1436
1603
  --pretty Indented JSON output.
1437
1604
 
1438
- Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
1605
+ Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.
1606
+
1607
+ Exit codes:
1608
+ 0 verification succeeded
1609
+ 1 generic failure
1610
+ 6 TAMPERED (sidecar or signature mismatch on the prior attestation)`,
1439
1611
  "list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
1440
1612
 
1441
1613
  Args / flags:
@@ -1446,26 +1618,38 @@ Lists every attestation under .exceptd/attestations/<session_id>/, sorted
1446
1618
  newest-first, with truncated evidence_hash + capture timestamp + file path.`,
1447
1619
  attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
1448
1620
 
1449
- Subverbs:
1621
+ Subverbs (list | show | export | verify | diff):
1450
1622
  attest show <sid> Emit the full (unredacted) attestation.
1451
1623
  attest list Inventory every prior attestation under
1452
1624
  ~/.exceptd/attestations/ (or EXCEPTD_HOME when set).
1453
- Filter with --playbook <id> or --since <ISO>. Newest
1454
- first; truncated evidence_hash + capture timestamp +
1455
- path per entry.
1625
+ Filter with --playbook <id> or --since <ISO> (must
1626
+ be a parseable ISO-8601 timestamp). Newest first;
1627
+ truncated evidence_hash + capture timestamp + path
1628
+ per entry.
1456
1629
  attest export <sid> Emit redacted JSON suitable for audit submission.
1457
1630
  Strips raw artifact values; preserves evidence_hash,
1458
1631
  signature, classification, RWEP, remediation choice.
1459
- --format <csaf|sarif|openvex> wraps the export in the
1460
- named envelope (default: redacted JSON).
1632
+ --format <csaf|csaf-2.0|json> wraps the export
1633
+ (default: redacted JSON; csaf yields a CSAF 2.0
1634
+ envelope).
1461
1635
  attest verify <sid> Verify .sig sidecar against keys/public.pem.
1462
- Reports tamper status per attestation file.
1636
+ Reports tamper status per attestation file. Replay
1637
+ records (kind=replay) verify under replay_results;
1638
+ a replay-record tamper raises body.replay_tamper +
1639
+ warnings[] but does NOT exit non-zero (the audit
1640
+ trail can be regenerated via reattest).
1463
1641
  attest diff <sid> Diff <sid> against the most-recent prior attestation
1464
1642
  for the same playbook, or against --against <other-sid>
1465
1643
  for an explicit pair. Reports unchanged | drifted |
1466
1644
  resolved per evidence_hash + classification deltas.
1467
1645
 
1468
- All subverbs honor --pretty for indented JSON output.`,
1646
+ All subverbs honor --pretty for indented JSON output.
1647
+
1648
+ Exit codes (attest verify):
1649
+ 0 verification succeeded
1650
+ 1 generic failure
1651
+ 6 TAMPERED (sidecar or signature mismatch on an attestation; replay-record
1652
+ tamper warns but exits 0)`,
1469
1653
  discover: `discover — context-aware playbook recommender (v0.11.0).
1470
1654
 
1471
1655
  Replaces: scan + dispatch + recommend.
@@ -1480,7 +1664,11 @@ Flags:
1480
1664
  --json Emit JSON (default is human-readable text).
1481
1665
  --pretty Indented JSON output (implies --json).
1482
1666
 
1483
- Output: context + recommended_playbooks[] + next_steps[].`,
1667
+ Output: context + recommended_playbooks[] + next_steps[].
1668
+
1669
+ discover always exits 0 (recommendations are informational; absence of a
1670
+ match is not a failure). JSON output is the canonical surface — humans see
1671
+ a digest by default; pass --json for the structured shape.`,
1484
1672
  doctor: `doctor — one-shot health check (v0.11.0).
1485
1673
 
1486
1674
  Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
@@ -1520,8 +1708,29 @@ Flags:
1520
1708
  --directive <id> Specific directive (default: first one).
1521
1709
  --no-stream Single-shot mode: emit all phases as one JSON doc
1522
1710
  without reading stdin (uses runner.run directly).
1711
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1712
+ bundle. One of: draft | interim (default) | final.
1713
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1714
+ set this only after operator review of the advisory.
1715
+ --publisher-namespace <url>
1716
+ CSAF document.publisher.namespace (§3.1.7.4). The
1717
+ operator's organisation URL, NOT the tooling vendor.
1718
+ Must be an http://… or https://… URL, ≤256 chars.
1719
+ --evidence <file|-> Single-shot mode: pre-supplied submission JSON.
1720
+ --operator <name> Bind the attestation to a specific identity.
1721
+ --ack Mark explicit operator consent (jurisdiction clock).
1722
+ --force-overwrite Override session-id collision refusal.
1723
+ --session-id <id> Reuse a specific session id (must satisfy
1724
+ /^[A-Za-z0-9._-]{1,64}$/).
1523
1725
  --pretty Indented JSON output (single-shot only).
1524
1726
 
1727
+ Exit codes:
1728
+ 0 done Run completed; emitted {"event":"done","ok":true}.
1729
+ 1 framework error Engine threw or stdin parse failure.
1730
+ 3 SESSION_ID_COLLISION --session-id duplicate; pass --force-overwrite or fresh id.
1731
+ 8 LOCK_CONTENTION Concurrent persistAttestation lock held.
1732
+ 9 STORAGE_EXHAUSTED Disk/quota/RO filesystem on attestation write.
1733
+
1525
1734
  Stdin event grammar (one JSON object per line):
1526
1735
  {"event":"evidence","payload":{"observations":{},"verdict":{}}}
1527
1736
 
@@ -1555,7 +1764,9 @@ Args / flags:
1555
1764
 
1556
1765
  Output: { verb, question, routed_to:[ids], confidence, next_step,
1557
1766
  full_match_list }. Empty match list when no token overlap — surfaces a
1558
- hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.`,
1767
+ hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.
1768
+
1769
+ ask always exits 0. JSON via --json (default is a one-line digest on TTY).`,
1559
1770
  ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
1560
1771
 
1561
1772
  Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
@@ -1601,6 +1812,9 @@ Exit codes:
1601
1812
  regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
1602
1813
  etc.) and the operator has not acked.
1603
1814
 
1815
+ (ci does not persist attestations per-run; exit codes 6/7/8/9 surface on
1816
+ \`attest verify\` and on \`run\` / \`ai-run\` / \`ingest\`, not on \`ci\`.)
1817
+
1604
1818
  Output: verb, session_id, playbooks_run, summary{total, detected,
1605
1819
  max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
1606
1820
  results[].`,
@@ -1652,7 +1866,15 @@ Flags: --pretty.`,
1652
1866
  Identical exit-code and output contract as \`run --all\`. Maintained for
1653
1867
  operators who script the verb form rather than the flag.
1654
1868
 
1655
- See \`exceptd run --help\` for the full flag list.`,
1869
+ Flags (selected — see \`exceptd run --help\` for the full list):
1870
+ --csaf-status <s> CSAF tracking.status for per-run close.evidence_package
1871
+ bundles. One of: draft | interim (default) | final.
1872
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1873
+ set this only after operator review of the advisory.
1874
+ --publisher-namespace <url>
1875
+ CSAF document.publisher.namespace (§3.1.7.4). The
1876
+ operator's organisation URL, NOT the tooling vendor.
1877
+ Must be an http://… or https://… URL, ≤256 chars.`,
1656
1878
  };
1657
1879
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
1658
1880
  }
@@ -1683,9 +1905,22 @@ function cmdLint(runner, args, runOpts, pretty) {
1683
1905
  if (!playbookId || !evidencePath) {
1684
1906
  return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
1685
1907
  }
1908
+ if (refuseInvalidPlaybookId("lint", playbookId, pretty)) return;
1686
1909
  let pb;
1687
1910
  try { pb = runner.loadPlaybook(playbookId); }
1688
- catch (e) { return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty); }
1911
+ catch (e) {
1912
+ // Route the not-found / load-error case through the skill-to-playbook
1913
+ // hint helper so an operator who typed a skill id (kernel-lpe-triage)
1914
+ // gets the same actionable pointer dispatchPlaybook surfaces for cmdRun.
1915
+ const m = e && e.message && e.message.match(/^Playbook not found: ([^\s(]+)/);
1916
+ if (m) {
1917
+ const hint = buildSkillToPlaybookHint(runner, m[1]);
1918
+ if (hint) {
1919
+ return emitError(`lint: Playbook not found: "${m[1]}". ${hint}`, { playbook: playbookId, type: "playbook_not_found" }, pretty);
1920
+ }
1921
+ }
1922
+ return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty);
1923
+ }
1689
1924
 
1690
1925
  let submission;
1691
1926
  try { submission = readEvidence(evidencePath); }
@@ -1824,6 +2059,7 @@ function cmdBrief(runner, args, runOpts, pretty) {
1824
2059
  return cmdPlan(runner, args, runOpts, pretty);
1825
2060
  }
1826
2061
 
2062
+ if (refuseInvalidPlaybookId("brief", playbookId, pretty)) return;
1827
2063
  const pb = runner.loadPlaybook(playbookId);
1828
2064
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
1829
2065
 
@@ -1982,6 +2218,41 @@ function validateScopeOrThrow(scope) {
1982
2218
  return scope;
1983
2219
  }
1984
2220
 
2221
+ /**
2222
+ * Wrap every operator-controlled loadPlaybook() call so a path-traversal
2223
+ * shaped id (`../../etc/passwd`, `..`, absolute path) is refused at the
2224
+ * dispatcher before the runner ever sees it. Routes through
2225
+ * validateIdComponent('playbook'), which enforces /^[a-z][a-z0-9-]{0,63}$/.
2226
+ * On failure returns the structured emitError shape; on success returns
2227
+ * null so the caller can short-circuit with a single `if (refusal) return refusal;`.
2228
+ */
2229
+ function refuseInvalidPlaybookId(verb, playbookId, pretty) {
2230
+ const r = validateIdComponent(playbookId, "playbook");
2231
+ if (!r.ok) {
2232
+ emitError(
2233
+ `${verb}: invalid <playbook> id — ${r.reason}.`,
2234
+ { verb, provided: typeof playbookId === "string" ? playbookId.slice(0, 80) : typeof playbookId },
2235
+ pretty
2236
+ );
2237
+ return true;
2238
+ }
2239
+ return false;
2240
+ }
2241
+
2242
+ /**
2243
+ * Shared "playbook has no directives" refusal. Six sites in this file
2244
+ * previously hand-rolled the same error string; consolidating means a
2245
+ * future remediation pointer (e.g. "run `exceptd brief <id>` to inspect
2246
+ * the playbook") changes in one place.
2247
+ */
2248
+ function refuseNoDirectives(verb, playbookId, pretty) {
2249
+ return emitError(
2250
+ `${verb}: playbook ${playbookId} has no directives. Inspect the playbook with \`exceptd brief ${playbookId}\` or report at https://github.com/blamejs/exceptd-skills/issues.`,
2251
+ { verb, playbook: playbookId },
2252
+ pretty
2253
+ );
2254
+ }
2255
+
1985
2256
  function filterPlaybooksByScope(runner, scope) {
1986
2257
  validateScopeOrThrow(scope);
1987
2258
  const ids = runner.listPlaybooks();
@@ -2024,27 +2295,30 @@ function detectScopes() {
2024
2295
  function cmdGovern(runner, args, runOpts, pretty) {
2025
2296
  const playbookId = args._[0];
2026
2297
  if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
2298
+ if (refuseInvalidPlaybookId("govern", playbookId, pretty)) return;
2027
2299
  const pb = runner.loadPlaybook(playbookId);
2028
2300
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2029
- if (!directiveId) return emitError(`govern: playbook ${playbookId} has no directives.`, null, pretty);
2301
+ if (!directiveId) return refuseNoDirectives("govern", playbookId, pretty);
2030
2302
  emit(runner.govern(playbookId, directiveId, runOpts), pretty);
2031
2303
  }
2032
2304
 
2033
2305
  function cmdDirect(runner, args, pretty) {
2034
2306
  const playbookId = args._[0];
2035
2307
  if (!playbookId) return emitError("direct: missing <playbookId> positional argument.", null, pretty);
2308
+ if (refuseInvalidPlaybookId("direct", playbookId, pretty)) return;
2036
2309
  const pb = runner.loadPlaybook(playbookId);
2037
2310
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2038
- if (!directiveId) return emitError(`direct: playbook ${playbookId} has no directives.`, null, pretty);
2311
+ if (!directiveId) return refuseNoDirectives("direct", playbookId, pretty);
2039
2312
  emit(runner.direct(playbookId, directiveId), pretty);
2040
2313
  }
2041
2314
 
2042
2315
  function cmdLook(runner, args, runOpts, pretty) {
2043
2316
  const playbookId = args._[0];
2044
2317
  if (!playbookId) return emitError("look: missing <playbookId> positional argument.", null, pretty);
2318
+ if (refuseInvalidPlaybookId("look", playbookId, pretty)) return;
2045
2319
  const pb = runner.loadPlaybook(playbookId);
2046
2320
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2047
- if (!directiveId) return emitError(`look: playbook ${playbookId} has no directives.`, null, pretty);
2321
+ if (!directiveId) return refuseNoDirectives("look", playbookId, pretty);
2048
2322
  emit(runner.look(playbookId, directiveId, runOpts), pretty);
2049
2323
  }
2050
2324
 
@@ -2054,13 +2328,12 @@ function cmdRun(runner, args, runOpts, pretty) {
2054
2328
  // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
2055
2329
  // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
2056
2330
  // from the cwd.
2057
- // R-F9: gate on `args.scope !== undefined` rather than `args.scope`
2058
- // truthy. Pre-fix, `--scope ""` parsed to `args.scope === ""`, which
2059
- // is falsy the dispatcher fell through to the auto-detect path and
2060
- // silently ran whatever scopes happened to match the cwd, masking the
2061
- // operator's explicit (if malformed) intent. Now: an empty string
2062
- // reaches validateScopeOrThrow which rejects with the accepted-set
2063
- // message, matching the rest of the v0.12.15 scope-validation contract.
2331
+ // Gate on `args.scope !== undefined` rather than truthy `args.scope`.
2332
+ // `--scope ""` parses to `args.scope === ""`, which is falsy; a truthy
2333
+ // gate would silently fall through to auto-detect and run whatever
2334
+ // scopes happened to match the cwd, masking the operator's explicit
2335
+ // (if malformed) intent. An empty string reaches validateScopeOrThrow
2336
+ // which rejects with the accepted-set message.
2064
2337
  if (!positional && (args.all || args.scope !== undefined)) {
2065
2338
  let ids;
2066
2339
  if (args.all) {
@@ -2076,16 +2349,32 @@ function cmdRun(runner, args, runOpts, pretty) {
2076
2349
  const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
2077
2350
  const unique = [...new Set(ids)];
2078
2351
  if (unique.length === 0) {
2079
- return emitError("run: no playbook resolved. Pass <playbookId>, --scope <type>, or --all.", null, pretty);
2352
+ // Surface the auto-detect failure cause so operators see WHY no
2353
+ // playbook was resolved instead of just "nothing matched." Mirrors
2354
+ // detectScopes()' two probes — `.git/` for code, `/proc + os-release`
2355
+ // for system — and enumerates the accepted explicit flags so the
2356
+ // remediation is one line.
2357
+ const hasGit = fs.existsSync(path.join(process.cwd(), ".git"));
2358
+ const hasProc = fs.existsSync("/proc") && fs.existsSync("/etc/os-release");
2359
+ const probes = [];
2360
+ if (!hasGit) probes.push("no .git/ in cwd (code-scope auto-detect skipped)");
2361
+ if (!hasProc) probes.push("no /proc + /etc/os-release (system-scope auto-detect skipped — not a Linux host or under sandbox)");
2362
+ const reason = probes.length ? ` Auto-detect probes: ${probes.join("; ")}.` : "";
2363
+ return emitError(
2364
+ `run: no playbook resolved. Pass <playbookId>, --scope <type> (one of ${JSON.stringify(VALID_SCOPES)}), or --all.${reason}`,
2365
+ { verb: "run", cwd: process.cwd(), detected_scopes: scopes },
2366
+ pretty
2367
+ );
2080
2368
  }
2081
2369
  return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
2082
2370
  }
2083
2371
 
2084
2372
  // Single-playbook path (existing behavior).
2085
2373
  const playbookId = positional;
2374
+ if (refuseInvalidPlaybookId("run", playbookId, pretty)) return;
2086
2375
  const pb = runner.loadPlaybook(playbookId);
2087
2376
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2088
- if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
2377
+ if (!directiveId) return refuseNoDirectives("run", playbookId, pretty);
2089
2378
 
2090
2379
  // --explain: dry-run that emits the preconditions + artifacts + indicators
2091
2380
  // + signal keys the agent would need to supply, WITHOUT running detect/
@@ -2138,20 +2427,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2138
2427
  // v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
2139
2428
  // pipe, assume `--evidence -`. Operators forgetting the flag previously
2140
2429
  // got a confusing precondition halt; now the common case "just works."
2141
- // R-F3: use truthy `!process.stdin.isTTY` instead of `=== false`. On
2142
- // Windows MSYS bash, process.stdin.isTTY is `undefined` for a piped
2143
- // stream the strict `=== false` check failed and auto-detect never
2144
- // fired, making `echo '{...}' | exceptd run <pb>` silently behave like
2145
- // no-evidence on Windows. cmdAiRun's path (below) already uses the
2146
- // truthy form, so this brings cmdRun + cmdIngest to parity.
2147
- //
2148
- // EE P1-7: use the fstat-probing hasReadableStdin() helper instead of
2149
- // the raw `!process.stdin.isTTY` truthy check. Test harnesses with
2150
- // wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
2151
- // isTTY === undefined but have no data — the raw check fell into
2152
- // readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
2153
- // hasReadableStdin() does an fstat() probe first, then falls back to
2154
- // the truthy check only on Windows (where fstat on a pipe is unreliable).
2430
+ // Use the fstat-probing hasReadableStdin() helper. A raw `!isTTY` check
2431
+ // fires when isTTY is undefined (test harnesses with wrapped duplexers —
2432
+ // Mocha/Jest, Docker stdin-passthrough leave isTTY === undefined but
2433
+ // never write any bytes), which causes readFileSync(0) to block waiting
2434
+ // on an EOF that never arrives. hasReadableStdin() does an fstat() probe
2435
+ // first, then falls back to a strict isTTY===false check only on Windows
2436
+ // (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
2437
+ // isTTY === false for genuine piped input, so that path still works.
2155
2438
  if (!args.evidence && hasReadableStdin()) {
2156
2439
  args.evidence = "-";
2157
2440
  }
@@ -2187,11 +2470,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2187
2470
  // CVE ID set through to analyze() so matched_cves drops them.
2188
2471
  if (args.vex) {
2189
2472
  let vexDoc;
2190
- // R-F5: cap --vex file size the same way readEvidence() caps --evidence
2191
- // (32 MiB — binary mebibytes, i.e. 32 * 1024 * 1024 = 33,554,432 bytes).
2192
- // Pre-fix, --vex did a raw readFileSync with no size check an operator
2193
- // passing a multi-GB file (binary log, JSON bomb, or accident) blocked
2194
- // the event loop for minutes / OOM'd the process. 32 MiB is well beyond
2473
+ // Cap --vex file size at 32 MiB (binary mebibytes, i.e. 32 * 1024 * 1024
2474
+ // = 33,554,432 bytes), matching readEvidence()'s --evidence cap. Without
2475
+ // the cap, a multi-GB file (binary log, JSON bomb, or accident) blocks
2476
+ // the event loop for minutes / OOM's the process. 32 MiB is well beyond
2195
2477
  // any legitimate VEX submission.
2196
2478
  const MAX_VEX_BYTES = 32 * 1024 * 1024;
2197
2479
  let vstat;
@@ -2200,7 +2482,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2200
2482
  return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
2201
2483
  }
2202
2484
  if (vstat.size > MAX_VEX_BYTES) {
2203
- // EE P1-4: error message names the binary mebi convention explicitly so
2485
+ // Error message names the binary mebi convention explicitly so
2204
2486
  // operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
2205
2487
  return emitError(
2206
2488
  `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.`,
@@ -2209,14 +2491,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2209
2491
  );
2210
2492
  }
2211
2493
  try {
2212
- // EE P1-2: BOM-tolerant read. Windows-tool-emitted CycloneDX commonly
2213
- // carries UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
2494
+ // BOM-tolerant read. Windows-tool-emitted CycloneDX commonly carries
2495
+ // UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
2214
2496
  // readFileSync chokes on the leading 0xFEFF.
2215
2497
  vexDoc = readJsonFile(args.vex);
2216
2498
  } catch (e) {
2217
2499
  return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
2218
2500
  }
2219
- // F5: validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
2501
+ // Validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
2220
2502
  // The runner tolerantly returns Set(0) for anything that's not CycloneDX
2221
2503
  // or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
2222
2504
  // advisory by mistake got ZERO filter applied and ZERO feedback. Now:
@@ -2234,15 +2516,13 @@ function cmdRun(runner, args, runOpts, pretty) {
2234
2516
  const vexSet = runner.vexFilterFromDoc(vexDoc);
2235
2517
  submission.signals = submission.signals || {};
2236
2518
  submission.signals.vex_filter = [...vexSet];
2237
- // BB P1-3: vexFilterFromDoc attaches a `.fixed` Set as an own property
2238
- // on the returned filter Set (CycloneDX `analysis.state: 'resolved'`
2239
- // + OpenVEX `status: 'fixed'` go into this set). Without forwarding it
2240
- // through to signals.vex_fixed, analyze() never receives the fixed-
2241
- // disposition CVE ids — `vexFixed` stays null, `vex_status: 'fixed'`
2242
- // never gets annotated onto matched_cves entries, and CSAF
2243
- // product_status.fixed + OpenVEX status:'fixed' are unreachable from
2244
- // the CLI. The bundle-correctness tests only exercised the analyze()
2245
- // direct-call path with vex_fixed pre-injected, hiding this regression.
2519
+ // vexFilterFromDoc attaches a `.fixed` Set as an own property on the
2520
+ // returned filter Set (CycloneDX `analysis.state: 'resolved'` + OpenVEX
2521
+ // `status: 'fixed'` populate it). Forward it through to
2522
+ // signals.vex_fixed so analyze() receives the fixed-disposition CVE
2523
+ // ids, `vex_status: 'fixed'` annotates matched_cves entries, and CSAF
2524
+ // product_status.fixed + OpenVEX status:'fixed' propagate into the
2525
+ // bundle.
2246
2526
  submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
2247
2527
  } catch (e) {
2248
2528
  return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
@@ -2281,14 +2561,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2281
2561
  // ack state by that name (`result.ack` is shorter + matches the CLI flag).
2282
2562
  if (result && runOpts.operator) result.operator = runOpts.operator;
2283
2563
 
2284
- // EE P1-6: --ack consent only counts when a jurisdiction clock is actually
2285
- // at stake — i.e. the run produced classification=detected (a real finding
2564
+ // --ack consent only counts when a jurisdiction clock is actually at
2565
+ // stake — i.e. the run produced classification=detected (a real finding
2286
2566
  // that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
2287
- // not-detected or inconclusive run, persisting the consent silently was
2288
- // misleading: the attestation file recorded operator acknowledgement of
2289
- // a clock that never started. Now: surface the ack state in the run body
2290
- // either way so operators see what happened, but only persist
2291
- // `operator_consent` into the attestation when classification === detected.
2567
+ // not-detected or inconclusive run, persisting the consent would record
2568
+ // operator acknowledgement of a clock that never started. Surface the
2569
+ // ack state in the run body either way so operators see what happened,
2570
+ // but only persist `operator_consent` into the attestation when
2571
+ // classification === detected.
2292
2572
  const detectClassification = result && result.phases && result.phases.detect
2293
2573
  ? result.phases.detect.classification
2294
2574
  : null;
@@ -2313,7 +2593,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2313
2593
  directiveId: result.directive_id,
2314
2594
  evidenceHash: result.evidence_hash,
2315
2595
  operator: runOpts.operator,
2316
- // EE P1-6: gate consent persistence on classification=detected.
2596
+ // Gate consent persistence on classification=detected.
2317
2597
  operatorConsent: consentApplies ? runOpts.operator_consent : null,
2318
2598
  submission,
2319
2599
  runOpts,
@@ -2322,31 +2602,34 @@ function cmdRun(runner, args, runOpts, pretty) {
2322
2602
  });
2323
2603
  if (!persistResult.ok) {
2324
2604
  // Session-id collision without --force-overwrite, OR --force-overwrite
2325
- // lost the lockfile race. Refuse, surface the existing path so the
2326
- // operator can decide, emit JSON to stderr matching the unified error
2327
- // shape. Exit non-zero a silent overwrite is a tamper-evidence
2328
- // violation. v0.12.14: exitCode + return instead of process.exit so
2329
- // the stderr line drains under piped CI consumers.
2330
- //
2331
- // When persistAttestation lost the lockfile race it pinned
2332
- // process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
2333
- // overwrite that with 3 — preserve the exit-8 contract callers depend
2334
- // on to distinguish lock-busy from collision.
2605
+ // lost the lockfile race, OR the filesystem refused the write (full
2606
+ // disk, quota, read-only). Three distinct exit-code classes:
2607
+ // 8 LOCK_CONTENTION retry from the outside (transient)
2608
+ // 9 STORAGE_EXHAUSTED — disk/quota/RO operator-side infra fix
2609
+ // 7 SESSION_ID_COLLISION — pass --force-overwrite or fresh id
2610
+ // Route through emitError() shape so the body goes to stderr and exit
2611
+ // codes propagate via the emit() contract.
2335
2612
  const err = {
2336
2613
  ok: false,
2337
2614
  error: persistResult.error,
2338
2615
  existing_attestation: persistResult.existingPath,
2339
- hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
2616
+ hint: persistResult.storage_exhausted
2617
+ ? "Free disk space, lift quota, or remount the attestation root read-write; then retry."
2618
+ : "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
2340
2619
  verb: "run",
2341
2620
  };
2342
2621
  if (persistResult.lock_contention) {
2343
2622
  err.lock_contention = true;
2344
- err.exit_code = 8;
2623
+ err.exit_code = EXIT_CODES.LOCK_CONTENTION;
2345
2624
  }
2346
- process.stderr.write(JSON.stringify(err) + "\n");
2347
- if (!persistResult.lock_contention) {
2348
- process.exitCode = 3;
2625
+ if (persistResult.storage_exhausted) {
2626
+ err.storage_exhausted = true;
2627
+ err.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
2349
2628
  }
2629
+ emitError(persistResult.error, err, pretty);
2630
+ if (persistResult.lock_contention) process.exitCode = EXIT_CODES.LOCK_CONTENTION;
2631
+ else if (persistResult.storage_exhausted) process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
2632
+ else process.exitCode = EXIT_CODES.SESSION_ID_COLLISION;
2350
2633
  return;
2351
2634
  }
2352
2635
  if (persistResult.prior_session_id) {
@@ -2360,14 +2643,13 @@ function cmdRun(runner, args, runOpts, pretty) {
2360
2643
  }
2361
2644
 
2362
2645
  if (result && result.ok === false) {
2363
- // F19: align preflight-halt exit code between `run --ci` and `ci`.
2364
- // Pre-fix `run --ci` exited 1 (FRAMEWORK_ERROR) while `ci` on the same
2365
- // halt exited 4 (BLOCKED). Now both use 4 when --ci is in effect, so
2366
- // operators can wire one set of exit-code expectations regardless of
2367
- // which verb they call. Without --ci the legacy exit 1 is preserved
2368
- // (ok:false bodies are framework signals when no CI gating is asked for).
2646
+ // Align preflight-halt exit code between `run --ci` and `ci`: both use
2647
+ // 4 (BLOCKED) when --ci is in effect so operators can wire one set of
2648
+ // exit-code expectations regardless of which verb they call. Without
2649
+ // --ci the legacy exit 1 is preserved (ok:false bodies are framework
2650
+ // signals when no CI gating is requested).
2369
2651
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
2370
- process.exitCode = args.ci ? 4 : 1;
2652
+ process.exitCode = args.ci ? EXIT_CODES.BLOCKED : EXIT_CODES.GENERIC_FAILURE;
2371
2653
  return;
2372
2654
  }
2373
2655
 
@@ -2388,11 +2670,11 @@ function cmdRun(runner, args, runOpts, pretty) {
2388
2670
  result.strict_preconditions_violated = warnIssues.map(i => ({
2389
2671
  id: i.id, kind: i.kind, message: i.message || null, on_fail: i.on_fail || null,
2390
2672
  }));
2391
- process.stderr.write(`[exceptd run] --strict-preconditions: ${warnIssues.length} unverified/warn precondition(s) — exit 1.\n`);
2673
+ process.stderr.write(`[exceptd run] --strict-preconditions: ${warnIssues.length} unverified/warn precondition(s) — exit ${EXIT_CODES.GENERIC_FAILURE}.\n`);
2392
2674
  emit(result, pretty);
2393
2675
  // v0.11.11: exitCode + return so emit()'s stdout flushes (process.exit
2394
2676
  // can truncate buffered async stdout writes when piped).
2395
- process.exitCode = 1;
2677
+ process.exitCode = EXIT_CODES.GENERIC_FAILURE;
2396
2678
  return;
2397
2679
  }
2398
2680
  }
@@ -2430,9 +2712,9 @@ function cmdRun(runner, args, runOpts, pretty) {
2430
2712
  const refs = startedClocks
2431
2713
  .map(n => `${n.obligation_ref || n.jurisdiction || "?"}@${n.clock_started_at}`)
2432
2714
  .join("; ");
2433
- process.stderr.write(`[exceptd run --block-on-jurisdiction-clock] CLOCK_STARTED: ${startedClocks.length} jurisdiction clock(s) running and unacked: ${refs}. Exit 5.\n`);
2715
+ process.stderr.write(`[exceptd run --block-on-jurisdiction-clock] CLOCK_STARTED: ${startedClocks.length} jurisdiction clock(s) running and unacked: ${refs}. Exit ${EXIT_CODES.JURISDICTION_CLOCK_STARTED}.\n`);
2434
2716
  emit(result, pretty);
2435
- process.exitCode = 5;
2717
+ process.exitCode = EXIT_CODES.JURISDICTION_CLOCK_STARTED;
2436
2718
  return;
2437
2719
  }
2438
2720
  }
@@ -2469,12 +2751,12 @@ function cmdRun(runner, args, runOpts, pretty) {
2469
2751
  // under piped consumers (CI runners, jq, test harnesses).
2470
2752
  if (classification === "detected") {
2471
2753
  process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
2472
- process.exitCode = 2;
2754
+ process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
2473
2755
  return;
2474
2756
  }
2475
2757
  if (classification === "inconclusive" && escalate) {
2476
2758
  process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
2477
- process.exitCode = 2;
2759
+ process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
2478
2760
  return;
2479
2761
  }
2480
2762
  if (classification === "inconclusive") {
@@ -2573,9 +2855,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2573
2855
  const top = rwep?.threshold?.escalate ?? "n/a";
2574
2856
  const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
2575
2857
  lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
2576
- // F11: surface --diff-from-latest verdict in the human renderer. Pre-fix
2577
- // operators had to add --json to see whether the run drifted from the
2578
- // previous attestation. Now one summary line follows the classification.
2858
+ // F11: surface --diff-from-latest verdict in the human renderer so
2859
+ // operators see whether the run drifted from the previous attestation
2860
+ // without adding --json. One summary line follows the classification.
2861
+ // Marker text is grep-matched by tests/audit-i-l-m-fixes.test.js F11.
2579
2862
  // - unchanged: same evidence_hash as prior → reassuring single line.
2580
2863
  // - drifted: evidence differs → loud DRIFTED marker.
2581
2864
  // - no_prior_attestation_for_playbook: no line — don't clutter the
@@ -2653,7 +2936,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2653
2936
  * inconclusive findings + visibility gaps) when no --evidence is given.
2654
2937
  */
2655
2938
  /**
2656
- * F13: collapse per-playbook notification_actions into a deduped rollup.
2939
+ * Collapse per-playbook notification_actions into a deduped rollup.
2657
2940
  * Multi-playbook runs frequently surface the same jurisdiction clock from
2658
2941
  * 5-10 contributing playbooks (every EU-touching playbook starts a fresh
2659
2942
  * NIS2 Art.23 24h clock). Operators were drafting one notification per
@@ -2688,12 +2971,11 @@ function buildJurisdictionClockRollup(results) {
2688
2971
  existing.deadline = n.deadline;
2689
2972
  }
2690
2973
  } else {
2691
- // R-F11: emit `obligation` (the field name the CHANGELOG v0.12.16
2692
- // entry promised) AND retain `obligation_ref` as a kept-name alias
2974
+ // Emit `obligation` and retain `obligation_ref` as a kept-name alias
2693
2975
  // for any consumer that already parses the older shape. The dedupe
2694
2976
  // key still keys on n.obligation_ref since that's the field
2695
2977
  // notification-action stubs carry; the rollup body just exposes
2696
- // both names so the documented contract is truthful.
2978
+ // both names.
2697
2979
  const obligation = n.obligation_ref || null;
2698
2980
  m.set(key, {
2699
2981
  jurisdiction: n.jurisdiction || null,
@@ -2740,20 +3022,30 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2740
3022
  // symlink/junction inside the dir, but the filter is cheap.
2741
3023
  for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
2742
3024
  const pbId = f.replace(/\.json$/, "");
2743
- if (!/^[A-Za-z0-9_.-]+$/.test(pbId)) {
2744
- return emitError(`run: --evidence-dir entry ${JSON.stringify(f)} has unsafe playbook-id segment.`, null, pretty);
3025
+ // Reuse the shared playbook-id validator so the --evidence-dir entry
3026
+ // filter agrees with the runtime playbook-id allowlist. Previously
3027
+ // accepted dots / underscores / uppercase that no real playbook id
3028
+ // uses, which would silently absorb a typo'd filename as a "valid"
3029
+ // entry that loadPlaybook then refused mid-loop.
3030
+ const pbCheck = validateIdComponent(pbId, "playbook");
3031
+ if (!pbCheck.ok) {
3032
+ return emitError(
3033
+ `run: --evidence-dir entry ${JSON.stringify(f)} has invalid playbook-id segment (${pbCheck.reason}).`,
3034
+ { entry: f, expected_shape: "<playbook-id>.json (lowercase, starts with letter, no dots)" },
3035
+ pretty
3036
+ );
2745
3037
  }
2746
3038
  const entryPath = path.resolve(path.join(resolvedDir, f));
2747
3039
  if (!entryPath.startsWith(resolvedDir + path.sep)) {
2748
3040
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
2749
3041
  }
2750
- // R-F12: the path.resolve check above only catches `..` traversal in
2751
- // the joined path; fs.readFileSync(entryPath) still follows symlinks,
2752
- // so a `<pb-id>.json -> /etc/shadow` symlink inside the dir would
2753
- // happily slurp the target. lstat is symlink-aware (it does NOT
2754
- // follow); refuse anything that's not a regular file. Defense in
2755
- // depth on top of the readdir filter — a junction (Windows) or
2756
- // bind-mount can shape-shift in between filter and read.
3042
+ // The path.resolve check above only catches `..` traversal in the
3043
+ // joined path; fs.readFileSync(entryPath) still follows symlinks, so
3044
+ // a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
3045
+ // slurp the target. lstat is symlink-aware (it does NOT follow);
3046
+ // refuse anything that's not a regular file. Defense in depth on top
3047
+ // of the readdir filter — a junction (Windows) or bind-mount can
3048
+ // shape-shift in between filter and read.
2757
3049
  let lst;
2758
3050
  try { lst = fs.lstatSync(entryPath); }
2759
3051
  catch (e) {
@@ -2765,9 +3057,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2765
3057
  if (!lst.isFile()) {
2766
3058
  return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2767
3059
  }
2768
- // EE P1-5: Windows directory junctions are reparse-point dirs that
3060
+ // Windows directory junctions are reparse-point dirs that
2769
3061
  // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
2770
- // ordinary directories). They bypass the symlink refusal above. Use
3062
+ // ordinary directories), bypassing the symlink refusal above. Use
2771
3063
  // realpathSync to resolve the entry and confirm it still lives under
2772
3064
  // the resolved evidence-dir — the realpath approach is portable
2773
3065
  // (catches POSIX symlinks too, defense in depth) and works regardless
@@ -2784,10 +3076,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2784
3076
  pretty
2785
3077
  );
2786
3078
  }
2787
- // EE P1-5 (hardlink defense in depth): no clean cross-platform refusal
2788
- // exists — hardlinks are indistinguishable from regular files at the
2789
- // inode level. Surface a stderr warning when nlink > 1 so the operator
2790
- // is aware a second name may point at the same file. Not a refusal —
3079
+ // Hardlink defense in depth: no clean cross-platform refusal exists —
3080
+ // hardlinks are indistinguishable from regular files at the inode
3081
+ // level. Surface a stderr warning when nlink > 1 so the operator is
3082
+ // aware a second name may point at the same file. Not a refusal —
2791
3083
  // legitimate use cases (atomic rename, package-manager dedup) produce
2792
3084
  // nlink > 1 without malicious intent.
2793
3085
  if (lst.nlink > 1) {
@@ -2803,10 +3095,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2803
3095
 
2804
3096
  const results = [];
2805
3097
  for (const id of ids) {
3098
+ // Defense-in-depth: ids come from listPlaybooks() / filterPlaybooksByScope
3099
+ // (which read trusted catalog data), but threading every id through
3100
+ // validateIdComponent('playbook') means a corrupt catalog cannot
3101
+ // path-traverse via this loop either.
3102
+ const r = validateIdComponent(id, "playbook");
3103
+ if (!r.ok) {
3104
+ results.push({ playbook_id: id, ok: false, error: `invalid playbook id (${r.reason})` });
3105
+ continue;
3106
+ }
2806
3107
  const pb = runner.loadPlaybook(id);
2807
3108
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2808
3109
  if (!directiveId) {
2809
- results.push({ playbook_id: id, ok: false, error: "no directives" });
3110
+ results.push({ playbook_id: id, ok: false, error: `playbook ${id} has no directives` });
2810
3111
  continue;
2811
3112
  }
2812
3113
  const submission = bundle[id] || {};
@@ -2815,16 +3116,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2815
3116
 
2816
3117
  const result = runner.run(id, directiveId, submission, perRunOpts);
2817
3118
 
2818
- // NN P1-4: mirror the cmdRun consent gate (EE P1-6). --ack consent only
2819
- // counts when a jurisdiction clock is actually at stake on THIS
2820
- // playbook's verdict — i.e. its detect.classification === 'detected'.
2821
- // Pre-fix cmdRunMulti passed `perRunOpts.operator_consent` for every
2822
- // playbook in the iteration regardless of that playbook's individual
2823
- // classification, so a single --ack on a run-all invocation persisted
2824
- // explicit consent into attestations whose run never started a clock.
2825
- // Now: per-playbook gating with the same `ack_skipped_reason` surface
2826
- // cmdRun emits, so consumers see exactly which playbooks consumed the
2827
- // ack and which didn't.
3119
+ // Per-playbook --ack gating: consent only counts when a jurisdiction
3120
+ // clock is actually at stake on THIS playbook's verdict — i.e. its
3121
+ // detect.classification === 'detected'. Without this gate, a single
3122
+ // --ack on a run-all invocation would persist explicit consent into
3123
+ // every playbook's attestation regardless of whether that playbook's
3124
+ // run started a clock. The `ack_skipped_reason` surface mirrors cmdRun
3125
+ // so consumers see exactly which playbooks consumed the ack.
2828
3126
  const perDetectClassification = result && result.phases && result.phases.detect
2829
3127
  ? result.phases.detect.classification
2830
3128
  : null;
@@ -2849,8 +3147,8 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2849
3147
  directiveId,
2850
3148
  evidenceHash: result.evidence_hash,
2851
3149
  operator: perRunOpts.operator,
2852
- // NN P1-4: gate consent persistence on this playbook's
2853
- // classification, not on the aggregate run's --ack presence.
3150
+ // Gate consent persistence on this playbook's classification, not
3151
+ // on the aggregate run's --ack presence.
2854
3152
  operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
2855
3153
  submission,
2856
3154
  runOpts: perRunOpts,
@@ -2860,8 +3158,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2860
3158
  if (!persisted.ok) {
2861
3159
  // Multi-run collision: don't abort the whole bundle; surface in the
2862
3160
  // per-playbook result so the operator can see exactly which
2863
- // playbook's attestation refused to overwrite.
3161
+ // playbook's attestation refused to overwrite. Propagate
3162
+ // lock_contention / storage_exhausted / exit_code so the aggregate
3163
+ // exit-code gate below picks the right top-level code (8 / 9 /
3164
+ // 7 / 1) instead of collapsing every persist failure to 1.
2864
3165
  result.attestation_persist = { ok: false, error: persisted.error };
3166
+ if (persisted.lock_contention) {
3167
+ result.attestation_persist.lock_contention = true;
3168
+ result.attestation_persist.exit_code = EXIT_CODES.LOCK_CONTENTION;
3169
+ }
3170
+ if (persisted.storage_exhausted) {
3171
+ result.attestation_persist.storage_exhausted = true;
3172
+ result.attestation_persist.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
3173
+ }
2865
3174
  } else if (persisted.prior_session_id) {
2866
3175
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
2867
3176
  }
@@ -2869,12 +3178,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2869
3178
  results.push(result);
2870
3179
  }
2871
3180
 
2872
- // F13: dedupe jurisdiction-clock notification actions across all playbook
2873
- // results into a single rollup. Pre-fix a 13-playbook multi-run with 8
2874
- // contributors of "EU NIS2 Art.23 24h" produced 8 separate entries, so
2875
- // operators drafted 8 NIS2 notifications when one was sufficient. Per-
2876
- // playbook entries are preserved on individual results; this rollup is
2877
- // additive — keyed on (jurisdiction, regulation, obligation_ref,
3181
+ // Dedupe jurisdiction-clock notification actions across all playbook
3182
+ // results into a single rollup. Without this, a 13-playbook multi-run
3183
+ // with 8 contributors of "EU NIS2 Art.23 24h" produces 8 separate
3184
+ // entries and operators draft 8 NIS2 notifications when one suffices.
3185
+ // Per-playbook entries are preserved on individual results; this rollup
3186
+ // is additive — keyed on (jurisdiction, regulation, obligation_ref,
2878
3187
  // window_hours) — with a triggered_by_playbooks[] list so operators see
2879
3188
  // which playbooks contributed.
2880
3189
  const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
@@ -2900,27 +3209,33 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2900
3209
  // the body but exit code stayed 0 — CI gates couldn't distinguish "ran
2901
3210
  // clean" from "blocked." v0.12.8: use exitCode (not process.exit()) so
2902
3211
  // the aggregate JSON emitted above is allowed to fully drain.
3212
+ //
3213
+ // Aggregate exit-code precedence: LOCK_CONTENTION > STORAGE_EXHAUSTED >
3214
+ // BLOCKED. Lock contention is transient (retry-from-outside fixes it);
3215
+ // storage exhaustion is an infra event requiring operator action;
3216
+ // ok:false in a per-playbook result is the BLOCKED case. Surfacing the
3217
+ // most-specific code first means a CI gate can branch on the right
3218
+ // remediation without parsing the body.
3219
+ const anyLockBusy = results.some(r => r.attestation_persist && r.attestation_persist.lock_contention === true);
3220
+ const anyStorageExhausted = results.some(r => r.attestation_persist && r.attestation_persist.storage_exhausted === true);
2903
3221
  const anyBlocked = results.some(r => r.ok === false);
2904
- if (anyBlocked) { process.exitCode = 1; return; }
3222
+ if (anyLockBusy) { process.exitCode = EXIT_CODES.LOCK_CONTENTION; return; }
3223
+ if (anyStorageExhausted) { process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED; return; }
3224
+ if (anyBlocked) { process.exitCode = EXIT_CODES.GENERIC_FAILURE; return; }
2905
3225
  }
2906
3226
 
2907
3227
  function cmdIngest(runner, args, runOpts, pretty) {
2908
3228
  // `ingest` matches the AGENTS.md ingest contract. The submission JSON may
2909
3229
  // carry playbook_id + directive_id; --domain/--directive flags override.
2910
3230
  let submission = {};
2911
- // F4: auto-detect piped stdin (parity with cmdRun). Without this,
2912
- // `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
2913
- // because args.evidence stayed undefined and the routing JSON never got
2914
- // read. Mirrors the cmdRun behavior at line 1614.
2915
- // R-F3: use truthy `!process.stdin.isTTY` rather than `=== false`. On
2916
- // Windows MSYS bash, isTTY is `undefined` for piped streams — the
2917
- // strict `=== false` check failed and ingest silently treated the
2918
- // routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
2919
- // detects piped stdin") was a no-op on Windows pre-fix.
2920
- //
2921
- // EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
2922
- // Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
2923
- // would otherwise block here forever on the readFileSync(0) call.
3231
+ // Auto-detect piped stdin (parity with cmdRun) so
3232
+ // `echo '{...}' | exceptd ingest` reads the routing JSON instead of
3233
+ // failing with "no playbook resolved" because args.evidence stays
3234
+ // undefined.
3235
+ // Route stdin auto-detection through hasReadableStdin() (see cmdRun for
3236
+ // rationale). Wrapped-stdin test harnesses (Mocha/Jest, Docker
3237
+ // stdin-passthrough) would otherwise block here forever on the
3238
+ // readFileSync(0) call when isTTY === undefined.
2924
3239
  if (!args.evidence && hasReadableStdin()) {
2925
3240
  args.evidence = "-";
2926
3241
  }
@@ -2933,11 +3248,12 @@ function cmdIngest(runner, args, runOpts, pretty) {
2933
3248
  }
2934
3249
  const playbookId = args.domain || submission.playbook_id || submission.domain;
2935
3250
  if (!playbookId) return emitError("ingest: no playbook resolved — pass --domain <id> or include playbook_id in evidence JSON.", null, pretty);
3251
+ if (refuseInvalidPlaybookId("ingest", playbookId, pretty)) return;
2936
3252
  const pb = runner.loadPlaybook(playbookId);
2937
3253
  const directiveId = args.directive
2938
3254
  || submission.directive_id
2939
3255
  || (pb.directives[0] && pb.directives[0].id);
2940
- if (!directiveId) return emitError(`ingest: playbook ${playbookId} has no directives.`, null, pretty);
3256
+ if (!directiveId) return refuseNoDirectives("ingest", playbookId, pretty);
2941
3257
 
2942
3258
  // Strip the routing keys so the runner only sees the contract shape it expects.
2943
3259
  const cleanedSubmission = {
@@ -2984,17 +3300,24 @@ function cmdIngest(runner, args, runOpts, pretty) {
2984
3300
  filename: "attestation.json",
2985
3301
  });
2986
3302
  if (!persisted.ok) {
2987
- // Surface the collision; do not silently clobber. Preserve
2988
- // LOCK_CONTENTION exit 8 set by persistAttestation when
2989
- // --force-overwrite hit the lockfile race.
3303
+ // Route every persist-failure shape through emitError so the
3304
+ // emit() ok:false exitCode contract applies uniformly. Three
3305
+ // exit classes: LOCK_CONTENTION (transient), STORAGE_EXHAUSTED
3306
+ // (infra), SESSION_ID_COLLISION (operator decision).
2990
3307
  const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
2991
3308
  if (persisted.lock_contention) {
2992
3309
  ctx.lock_contention = true;
2993
- ctx.exit_code = 8;
2994
- process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
2995
- return;
3310
+ ctx.exit_code = EXIT_CODES.LOCK_CONTENTION;
2996
3311
  }
2997
- return emitError(persisted.error, ctx, pretty);
3312
+ if (persisted.storage_exhausted) {
3313
+ ctx.storage_exhausted = true;
3314
+ ctx.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
3315
+ }
3316
+ emitError(persisted.error, ctx, pretty);
3317
+ if (persisted.lock_contention) process.exitCode = EXIT_CODES.LOCK_CONTENTION;
3318
+ else if (persisted.storage_exhausted) process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
3319
+ else process.exitCode = EXIT_CODES.SESSION_ID_COLLISION;
3320
+ return;
2998
3321
  }
2999
3322
  if (persisted.prior_session_id) {
3000
3323
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
@@ -3141,21 +3464,21 @@ function persistAttestation(args) {
3141
3464
  existingPath: path.relative(process.cwd(), filePath),
3142
3465
  };
3143
3466
  }
3144
- // T P1-2: serialize the read-prior + write-new sequence behind a
3145
- // lockfile so concurrent --force-overwrite invocations against the
3146
- // same session-id slot do not degrade to last-write-wins. Pattern
3147
- // matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
3148
- // .lock file with bounded retry, PID-liveness check on contention,
3149
- // mtime fallback for orphaned lockfiles.
3150
- // DD P1-2: MAX_RETRIES capped at 10 (was 50). persistAttestation is a
3151
- // sync function called from sync callers throughout the CLI, so the
3152
- // wait loop must busy-spin (no event-loop yield available). At 50
3153
- // retries × ~200ms backoff per spin the worst case was ~10s of pegged-
3154
- // CPU + frozen-event-loop stall under attestation contention. Capping
3155
- // at 10 bounds the freeze at ~1-2s; beyond that callers receive the
3156
- // LOCK_CONTENTION sentinel on the result object and can retry from the
3157
- // outside without holding the CPU. Async refactor of persistAttestation
3158
- // + every caller is a v0.13.0 candidate.
3467
+ // Serialize the read-prior + write-new sequence behind a lockfile so
3468
+ // concurrent --force-overwrite invocations against the same session-id
3469
+ // slot do not degrade to last-write-wins. Pattern matches
3470
+ // withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling .lock file
3471
+ // with bounded retry, PID-liveness check on contention, mtime fallback
3472
+ // for orphaned lockfiles.
3473
+ // DD P1-2: MAX_RETRIES is capped at 10. persistAttestation is sync and
3474
+ // called from sync callers, so the wait loop must busy-spin (no
3475
+ // event-loop yield available). A larger bound would peg the CPU and
3476
+ // freeze the event loop for multiple seconds under attestation
3477
+ // contention. Capping at 10 bounds the freeze at ~1-2s; beyond that
3478
+ // callers receive the LOCK_CONTENTION sentinel on the result object
3479
+ // and can retry from the outside without holding the CPU. Async
3480
+ // refactor of persistAttestation + every caller is a v0.13.0
3481
+ // candidate.
3159
3482
  const lockPath = filePath + ".lock";
3160
3483
  const MAX_RETRIES = 10;
3161
3484
  const STALE_LOCK_MS = 30_000;
@@ -3166,6 +3489,22 @@ function persistAttestation(args) {
3166
3489
  acquired = true;
3167
3490
  break;
3168
3491
  } catch (lockErr) {
3492
+ // Distinguish lockfile contention (EEXIST/EPERM = another holder)
3493
+ // from storage-exhaustion classes (ENOSPC = disk full,
3494
+ // EROFS = read-only fs, EDQUOT = quota exceeded). The latter are
3495
+ // infra-level failures that no amount of retry-spin will resolve;
3496
+ // surface them with a distinct exit code (STORAGE_EXHAUSTED = 9)
3497
+ // so operator runbooks can branch on "free disk" vs "retry".
3498
+ if (lockErr.code === "ENOSPC" || lockErr.code === "EROFS" || lockErr.code === "EDQUOT") {
3499
+ process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
3500
+ return {
3501
+ ok: false,
3502
+ error: `STORAGE_EXHAUSTED: ${lockErr.message}`,
3503
+ existingPath: path.relative(process.cwd(), filePath),
3504
+ storage_exhausted: true,
3505
+ exit_code: EXIT_CODES.STORAGE_EXHAUSTED,
3506
+ };
3507
+ }
3169
3508
  if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
3170
3509
  let reclaimed = false;
3171
3510
  try {
@@ -3194,28 +3533,25 @@ function persistAttestation(args) {
3194
3533
  }
3195
3534
  }
3196
3535
  if (!acquired) {
3197
- // DD P1-2: lock_contention sentinel so callers can distinguish a
3198
- // genuine lock-busy condition (retry-from-outside is the right move)
3199
- // from a hard failure (write error, permission denial). The sync
3200
- // spin budget was bounded above so we hit this return after ~1-2s
3201
- // of contention rather than the prior ~10s.
3536
+ // Surface lock_contention as a distinct sentinel so callers can
3537
+ // distinguish a genuine lock-busy condition (retry-from-outside is
3538
+ // the right move) from a hard failure (write error, permission
3539
+ // denial). The sync spin budget is bounded above so this return
3540
+ // fires after ~1-2s of contention.
3202
3541
  //
3203
- // PP P1-2: emit() auto-maps any ok:false body to process.exitCode = 1
3204
- // (it only writes exitCode = 1 when the current value is 0). Pre-fix
3205
- // the LOCK_CONTENTION return collapsed onto exit 1 along with every
3206
- // other hard failure defeating the "callers can distinguish
3207
- // lock-busy from hard failure" promise. Pin process.exitCode = 8
3208
- // HERE, before the caller hands the body to emit(); emit() will
3209
- // preserve the already-non-zero value. Exit code 8 is reserved
3210
- // exclusively for LOCK_CONTENTION (attestation persist); see the
3211
- // exit-code table in printGlobalHelp().
3212
- process.exitCode = 8;
3542
+ // emit() auto-maps any ok:false body to process.exitCode = 1 (only
3543
+ // when the current value is still 0). Pin process.exitCode = 8 HERE
3544
+ // before the caller hands the body to emit(); emit() preserves the
3545
+ // already-non-zero value. Exit code 8 is reserved exclusively for
3546
+ // LOCK_CONTENTION (attestation persist); see the exit-code table in
3547
+ // printGlobalHelp().
3548
+ process.exitCode = EXIT_CODES.LOCK_CONTENTION;
3213
3549
  return {
3214
3550
  ok: false,
3215
3551
  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.`,
3216
3552
  existingPath: path.relative(process.cwd(), filePath),
3217
3553
  lock_contention: true,
3218
- exit_code: 8,
3554
+ exit_code: EXIT_CODES.LOCK_CONTENTION,
3219
3555
  };
3220
3556
  }
3221
3557
  try {
@@ -3238,6 +3574,19 @@ function persistAttestation(args) {
3238
3574
  }
3239
3575
  }
3240
3576
  } catch (e) {
3577
+ // ENOSPC / EROFS / EDQUOT are storage-exhaustion classes — surface
3578
+ // them with a distinct sentinel + exit code so callers route them
3579
+ // through a different remediation path than generic write errors.
3580
+ if (e && (e.code === "ENOSPC" || e.code === "EROFS" || e.code === "EDQUOT")) {
3581
+ process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
3582
+ return {
3583
+ ok: false,
3584
+ error: `STORAGE_EXHAUSTED: ${e.message}`,
3585
+ existingPath: null,
3586
+ storage_exhausted: true,
3587
+ exit_code: EXIT_CODES.STORAGE_EXHAUSTED,
3588
+ };
3589
+ }
3241
3590
  return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
3242
3591
  }
3243
3592
  }
@@ -3253,7 +3602,7 @@ function persistAttestation(args) {
3253
3602
  * from "the .sig file was deleted by an attacker."
3254
3603
  */
3255
3604
  /**
3256
- * C: byte-stability normalize() for the attestation pipeline.
3605
+ * Byte-stability normalize() for the attestation pipeline.
3257
3606
  * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
3258
3607
  * normalize() implementations in lib/sign.js, lib/verify.js,
3259
3608
  * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
@@ -3279,13 +3628,13 @@ function maybeSignAttestation(filePath) {
3279
3628
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
3280
3629
  // verifier doesn't trust.
3281
3630
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3282
- // C: normalize attestation bytes before sign — strip leading
3283
- // UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
3631
+ // Normalize attestation bytes before sign — strip leading UTF-8 BOM +
3632
+ // collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
3284
3633
  // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
3285
3634
  // attestation file lives on disk under .exceptd/ and can pick up CRLF
3286
3635
  // through git-attribute / editor round-trips on Windows; without
3287
3636
  // normalization the sign/verify pair diverges on the same logical content.
3288
- // The byte-stability contract is now five sites; tests/normalize-contract
3637
+ // The byte-stability contract spans five sites; tests/normalize-contract
3289
3638
  // .test.js enforces byte-identical output across all of them.
3290
3639
  const rawContent = fs.readFileSync(filePath, "utf8");
3291
3640
  const content = normalizeAttestationBytes(rawContent);
@@ -3309,16 +3658,15 @@ function maybeSignAttestation(filePath) {
3309
3658
  key: privateKey,
3310
3659
  dsaEncoding: "ieee-p1363",
3311
3660
  });
3312
- // KK P1-1: the sidecar's Ed25519 signature covers ONLY the
3313
- // attestation file bytes. Fields that travel inside the .sig but are
3314
- // NOT in the signed message are replay-rewrite trivial: an attacker
3315
- // who can write the directory can mutate them without invalidating
3316
- // the signature. Drop `signed_at`, `signs_path`, `signs_sha256` from
3317
- // the sidecar shape they were unsigned metadata posing as
3318
- // attestation context. Operators reading freshness use filesystem
3319
- // mtime; the attestation file's `captured_at` field is what's
3320
- // signed. The sidecar now carries only the algorithm tag, the
3321
- // Ed25519 signature payload, and an explanatory note.
3661
+ // The sidecar's Ed25519 signature covers ONLY the attestation file
3662
+ // bytes. Fields that travel inside the .sig but are NOT in the signed
3663
+ // message are replay-rewrite trivial: an attacker who can write the
3664
+ // directory can mutate them without invalidating the signature. The
3665
+ // sidecar therefore carries only the algorithm tag, the Ed25519
3666
+ // signature payload, and an explanatory note no `signed_at`,
3667
+ // `signs_path`, or `signs_sha256`. Operators reading freshness use
3668
+ // filesystem mtime; the attestation file's `captured_at` field is
3669
+ // what's signed.
3322
3670
  fs.writeFileSync(sigPath, JSON.stringify({
3323
3671
  algorithm: "Ed25519",
3324
3672
  signature_base64: sig.toString("base64"),
@@ -3350,9 +3698,13 @@ function maybeSignAttestation(filePath) {
3350
3698
  * Validation regex + root-confinement check matches persistAttestation.
3351
3699
  */
3352
3700
  function validateSessionIdForRead(sessionId) {
3353
- if (typeof sessionId !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(sessionId)) {
3701
+ // Route through validateIdComponent('session') so the regex + all-dots
3702
+ // refusal stay aligned with the write-path validator in
3703
+ // persistAttestation. Single source of truth in lib/id-validation.js.
3704
+ const r = validateIdComponent(sessionId, "session");
3705
+ if (!r.ok) {
3354
3706
  throw new Error(
3355
- `Invalid session-id: ${JSON.stringify(sessionId).slice(0, 80)}. Must match /^[A-Za-z0-9._-]{1,64}$/.`
3707
+ `Invalid session-id: ${typeof sessionId === "string" ? JSON.stringify(sessionId).slice(0, 80) : typeof sessionId}. ${r.reason}.`
3356
3708
  );
3357
3709
  }
3358
3710
  return sessionId;
@@ -3411,6 +3763,12 @@ function walkAttestationDir(root, opts, candidates) {
3411
3763
  try {
3412
3764
  const p = path.join(sdir, f);
3413
3765
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
3766
+ // Replay records (kind: 'replay') are an audit trail of force-replay
3767
+ // overrides, not a separate attestation. They have no captured_at /
3768
+ // evidence_hash and must not surface as candidates for --latest.
3769
+ // Gate on the parsed kind so a renamed file cannot smuggle a replay
3770
+ // record into the listing.
3771
+ if (j && j.kind === "replay") continue;
3414
3772
  if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
3415
3773
  if (opts.since && (j.captured_at || "") < opts.since) continue;
3416
3774
  if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
@@ -3421,14 +3779,14 @@ function walkAttestationDir(root, opts, candidates) {
3421
3779
  }
3422
3780
 
3423
3781
  /**
3424
- * F10: factored Ed25519-sidecar verification used by both `attest verify`
3425
- * and `reattest`. Returns { file, signed, verified, reason } for a given
3782
+ * Factored Ed25519-sidecar verification used by both `attest verify` and
3783
+ * `reattest`. Returns { file, signed, verified, reason } for a given
3426
3784
  * attestation file path.
3427
3785
  *
3428
- * Pre-fix, cmdReattest read attestation.json via JSON.parse with no
3429
- * authenticity check. A tampered attestation was silently consumed and the
3430
- * drift verdict was computed against forged input. Now cmdReattest calls
3431
- * this and refuses on verify-fail unless --force-replay is set.
3786
+ * Callers must check `signed && verified` before consuming the
3787
+ * attestation. cmdReattest refuses to replay on verify-fail unless
3788
+ * --force-replay is set, so a tampered attestation cannot silently feed
3789
+ * forged input into the drift verdict.
3432
3790
  */
3433
3791
  function verifyAttestationSidecar(attFile) {
3434
3792
  const crypto = require("crypto");
@@ -3488,15 +3846,15 @@ function verifyAttestationSidecar(attFile) {
3488
3846
  }
3489
3847
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3490
3848
  }
3491
- // KK P1-3: strict algorithm check. Pre-fix the verifier branched only on
3492
- // `=== "unsigned"`; null, undefined, "RSA-PSS", arrays, etc. fell through
3493
- // to crypto.verify with the default Ed25519 args — which would either
3494
- // succeed against the wrong-algorithm signature bytes accidentally (an
3495
- // attacker who can write the sidecar can replay an existing Ed25519
3496
- // signature under a downgrade-bait algorithm tag) or throw a generic
3497
- // verify error. Refuse anything that isn't exactly "Ed25519" or
3498
- // "unsigned" with a structured tamper class so callers can route the
3499
- // refusal through the same exit-6 path as other tamper events.
3849
+ // Strict algorithm check. A branch on `=== "unsigned"` alone would let
3850
+ // null, undefined, "RSA-PSS", arrays, etc. fall through to crypto.verify
3851
+ // with default Ed25519 args — which can either succeed against
3852
+ // wrong-algorithm signature bytes accidentally (an attacker who can
3853
+ // write the sidecar replays an existing Ed25519 signature under a
3854
+ // downgrade-bait algorithm tag) or throw a generic verify error.
3855
+ // Refuse anything that isn't exactly "Ed25519" or "unsigned" with a
3856
+ // structured tamper class so callers can route the refusal through the
3857
+ // same exit-6 path as other tamper events.
3500
3858
  if (sigDoc.algorithm !== "Ed25519") {
3501
3859
  return {
3502
3860
  file: attFile,
@@ -3512,9 +3870,9 @@ function verifyAttestationSidecar(attFile) {
3512
3870
  let content;
3513
3871
  try {
3514
3872
  const raw = fs.readFileSync(attFile, "utf8");
3515
- // C: apply the same normalize() used by the signer so the
3516
- // verify path is byte-stable across CRLF / BOM churn (Windows checkout
3517
- // with core.autocrlf=true, editor round-trips, git-attributes flips).
3873
+ // Apply the same normalize() used by the signer so the verify path is
3874
+ // byte-stable across CRLF / BOM churn (Windows checkout with
3875
+ // core.autocrlf=true, editor round-trips, git-attributes flips).
3518
3876
  content = normalizeAttestationBytes(raw);
3519
3877
  }
3520
3878
  catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
@@ -3534,13 +3892,14 @@ function verifyAttestationSidecar(attFile) {
3534
3892
  }
3535
3893
 
3536
3894
  function cmdReattest(runner, args, runOpts, pretty) {
3537
- // F29: --since ISO-8601 validation parity with `attest list --since`
3538
- // (already fixed in v0.12.12). Pre-fix, an invalid date silently passed
3539
- // through to walkAttestationDir, where the lexical comparison either
3540
- // matched all or none unpredictably.
3895
+ const crypto = require("crypto");
3896
+ // Validate --since as ISO-8601, mirroring `attest list --since`. An
3897
+ // invalid date would otherwise pass through to walkAttestationDir, where
3898
+ // the lexical comparison either matches all or none unpredictably.
3541
3899
  if (args.since != null) {
3542
- // R-F10: regex BEFORE Date.parse — bare integers like "99" would
3543
- // otherwise parse as the year 1999 and silently filter wrong eras.
3900
+ // ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
3901
+ // would otherwise parse as the year 1999 and silently filter wrong
3902
+ // eras.
3544
3903
  const sinceErr = validateIsoSince(args.since);
3545
3904
  if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
3546
3905
  }
@@ -3564,35 +3923,31 @@ function cmdReattest(runner, args, runOpts, pretty) {
3564
3923
  return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
3565
3924
  }
3566
3925
 
3567
- // F10: verify the .sig sidecar BEFORE consuming the prior attestation.
3568
- // Pre-fix, a tampered attestation.json was silently parsed and the drift
3569
- // verdict was computed against forged input. Now: refuse on verify-fail
3926
+ // Verify the .sig sidecar BEFORE consuming the prior attestation. A
3927
+ // tampered attestation.json would otherwise be silently parsed and the
3928
+ // drift verdict computed against forged input. Refuse on verify-fail
3570
3929
  // with exit 6 (TAMPERED) unless --force-replay is explicitly set.
3571
3930
  // Unsigned attestations (no private key was available at run time) emit
3572
3931
  // a stderr warning but proceed — that's an operator config issue, not
3573
3932
  // tampering. `verified === false && signed === true` is the real tamper
3574
3933
  // signal.
3575
3934
  const verify = verifyAttestationSidecar(attFile);
3576
- // 2: collapse tamper-class detection. Any non-benign
3577
- // sidecar state (signed-but-invalid, sidecar-corrupt, unsigned-substitution)
3578
- // refuses replay unless --force-replay is set. The pre-fix shape only
3579
- // refused on `verify.signed && !verify.verified` (signed-tamper) and on
3580
- // `no .sig sidecar` (missing); corrupt-JSON sidecars and substituted
3581
- // "unsigned" sidecars on a host WITH a private key fell into the benign
3582
- // NOTE branch and replay proceeded against forged input.
3935
+ // Collapse tamper-class detection. Any non-benign sidecar state
3936
+ // (signed-but-invalid, sidecar-corrupt, unsigned-substitution) refuses
3937
+ // replay unless --force-replay is set. A predicate of only
3938
+ // `verify.signed && !verify.verified` would miss corrupt-JSON sidecars
3939
+ // and substituted "unsigned" sidecars on a host WITH a private key —
3940
+ // both of which let replay proceed against forged input.
3583
3941
  const isSignedTamper = verify.signed && !verify.verified;
3584
3942
  const isClassTamper = !verify.signed && (
3585
3943
  verify.tamper_class === "sidecar-corrupt"
3586
3944
  || verify.tamper_class === "unsigned-substitution"
3587
- // KK P1-3: extend tamper-class refusal to algorithm-unsupported sidecars
3588
- // (anything other than "Ed25519" or "unsigned"). Pre-fix, the verifier
3589
- // pre-strict-check would crypto.verify against default Ed25519 args and
3590
- // return signed:true + verified:false on failure which DID land in
3591
- // isSignedTamper. But a sidecar that throws inside crypto.verify (e.g.
3592
- // signature_base64 missing on the downgrade-bait shape) was routed
3593
- // through the catch block and emerged as signed:true + verified:false
3594
- // by happy accident. The strict pre-check now surfaces the class
3595
- // directly; refuse on that class too.
3945
+ // Extend tamper-class refusal to algorithm-unsupported sidecars
3946
+ // anything other than "Ed25519" or "unsigned". Without explicit
3947
+ // refusal, a sidecar that throws inside crypto.verify (e.g.
3948
+ // signature_base64 missing on a downgrade-bait shape) emerges as
3949
+ // signed:true + verified:false through the catch block by accident.
3950
+ // The strict pre-check surfaces the class directly; refuse on it too.
3596
3951
  || verify.tamper_class === "algorithm-unsupported"
3597
3952
  );
3598
3953
  if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
@@ -3607,7 +3962,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3607
3962
  hint: "If you have inspected the attestation and the divergence is benign (e.g. you re-signed manually), pass --force-replay.",
3608
3963
  };
3609
3964
  process.stderr.write(JSON.stringify(body) + "\n");
3610
- process.exitCode = 6;
3965
+ process.exitCode = EXIT_CODES.TAMPERED;
3611
3966
  return;
3612
3967
  }
3613
3968
  if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
@@ -3634,7 +3989,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3634
3989
  hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
3635
3990
  };
3636
3991
  process.stderr.write(JSON.stringify(body) + "\n");
3637
- process.exitCode = 6;
3992
+ process.exitCode = EXIT_CODES.TAMPERED;
3638
3993
  return;
3639
3994
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3640
3995
  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`);
@@ -3658,7 +4013,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3658
4013
  hint: "If the original attestation was legitimately produced without a private key, pass --force-replay. The replay body will record sidecar_verify: 'explicitly-unsigned' + force_replay: true.",
3659
4014
  };
3660
4015
  process.stderr.write(JSON.stringify(body) + "\n");
3661
- process.exitCode = 6;
4016
+ process.exitCode = EXIT_CODES.TAMPERED;
3662
4017
  return;
3663
4018
  } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
3664
4019
  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`);
@@ -3688,16 +4043,32 @@ function cmdReattest(runner, args, runOpts, pretty) {
3688
4043
  // Fallback: synthesise pass-through preconditions from the playbook so the
3689
4044
  // replay isn't blocked when the operator didn't originally pass them.
3690
4045
  try {
3691
- const pb = runner.loadPlaybook(prior.playbook_id);
3692
- const synth = {};
3693
- for (const pc of (pb._meta && pb._meta.preconditions) || []) synth[pc.id] = true;
3694
- replayOpts.precondition_checks = synth;
4046
+ // Defense-in-depth: the prior attestation's playbook_id came from
4047
+ // disk, but a malicious or corrupt prior could still smuggle an
4048
+ // invalid id. validateIdComponent refuses anything outside the
4049
+ // canonical playbook-id shape.
4050
+ const r = validateIdComponent(prior.playbook_id, "playbook");
4051
+ if (r.ok) {
4052
+ const pb = runner.loadPlaybook(prior.playbook_id);
4053
+ const synth = {};
4054
+ for (const pc of (pb._meta && pb._meta.preconditions) || []) synth[pc.id] = true;
4055
+ replayOpts.precondition_checks = synth;
4056
+ }
3695
4057
  } catch { /* ignore */ }
3696
4058
  }
3697
4059
  const replay = runner.run(prior.playbook_id, prior.directive_id, emptySubmission, replayOpts);
3698
4060
 
3699
4061
  if (!replay || replay.ok === false) {
3700
- return emitError(`reattest: replay failed: ${replay && replay.reason || "unknown"}`, { replay }, pretty);
4062
+ // When replay.reason is falsy, dump the available keys so an operator
4063
+ // can correlate the failure to a body field — pre-fix the error message
4064
+ // bottomed out at "unknown" with no breadcrumb into the runner output.
4065
+ const reason = (replay && replay.reason) || (replay && replay.error) || null;
4066
+ const keys = replay && typeof replay === "object" ? Object.keys(replay).join(",") : "(no body)";
4067
+ return emitError(
4068
+ `reattest: replay failed: ${reason || `no reason field — replay body keys: [${keys}]`}`,
4069
+ { replay, replay_body_keys: replay && typeof replay === "object" ? Object.keys(replay) : null },
4070
+ pretty
4071
+ );
3701
4072
  }
3702
4073
 
3703
4074
  const priorHash = prior.evidence_hash;
@@ -3722,52 +4093,80 @@ function cmdReattest(runner, args, runOpts, pretty) {
3722
4093
  const sidecarVerifyClass = classifySidecarVerify(verify);
3723
4094
  const forceReplay = !!args["force-replay"];
3724
4095
 
3725
- // KK P1-2: persist a `replay-<isoZ>.json` audit record under the session
3726
- // directory whenever cmdReattest produced a replay verdict. Pre-fix the
3727
- // force-replay branches emitted the override body to stdout but never
3728
- // wrote it to disk; once the operator's shell closed the override was
3729
- // invisible to any subsequent auditor. Now every replay writes a new
3730
- // file alongside the original attestation.json, signed via the standard
3731
- // maybeSignAttestation path so the audit chain remains tamper-evident.
3732
- // The file is picked up automatically by `attest verify <sid>` (which
3733
- // already iterates every *.json under the session dir).
4096
+ // Persist a `replay-<isoZ>.json` record under the session directory for
4097
+ // every cmdReattest replay verdict. Without disk persistence, a
4098
+ // force-replay override emitted to stdout becomes invisible to any
4099
+ // subsequent auditor once the operator's shell closes. Each replay
4100
+ // writes a new file alongside the original attestation.json, signed via
4101
+ // the standard maybeSignAttestation path so the audit chain remains
4102
+ // tamper-evident. The file is picked up automatically by
4103
+ // `attest verify <sid>` (which iterates every *.json under the session
4104
+ // dir).
3734
4105
  //
3735
4106
  // Filename shape: ISO-8601 uses ':' which the persistAttestation regex
3736
4107
  // refuses; substitute ':' with '-' and keep millisecond precision so
3737
4108
  // multiple replays in the same second do not collide on EEXIST. The
3738
4109
  // resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
3739
- const replayFilename = "replay-" + replayedAt.replace(/:/g, "-") + ".json";
4110
+ const replayBaseName = "replay-" + replayedAt.replace(/:/g, "-");
4111
+ const replayBody = {
4112
+ kind: "replay",
4113
+ session_id: sessionId,
4114
+ playbook_id: prior.playbook_id,
4115
+ directive_id: prior.directive_id,
4116
+ status,
4117
+ prior_evidence_hash: priorHash,
4118
+ replay_evidence_hash: newHash,
4119
+ prior_captured_at: prior.captured_at,
4120
+ replayed_at: replayedAt,
4121
+ replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
4122
+ replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
4123
+ sidecar_verify: verify,
4124
+ sidecar_verify_class: sidecarVerifyClass,
4125
+ force_replay: forceReplay,
4126
+ };
3740
4127
  let replayPersisted = null;
4128
+ let replayPath = null;
3741
4129
  try {
3742
- const replayBody = {
3743
- kind: "replay",
3744
- session_id: sessionId,
3745
- playbook_id: prior.playbook_id,
3746
- directive_id: prior.directive_id,
3747
- status,
3748
- prior_evidence_hash: priorHash,
3749
- replay_evidence_hash: newHash,
3750
- prior_captured_at: prior.captured_at,
3751
- replayed_at: replayedAt,
3752
- replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3753
- replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3754
- sidecar_verify: verify,
3755
- sidecar_verify_class: sidecarVerifyClass,
3756
- force_replay: forceReplay,
3757
- };
3758
- const replayPath = path.join(path.dirname(attFile), replayFilename);
3759
- // O_EXCL 'wx' millisecond-level filename + EEXIST refusal so two
3760
- // concurrent reattests do not silently overwrite each other.
3761
- fs.writeFileSync(replayPath, JSON.stringify(replayBody, null, 2), { flag: "wx" });
3762
- maybeSignAttestation(replayPath);
3763
- replayPersisted = { ok: true, path: replayPath };
4130
+ // Retry on EEXIST: two concurrent reattests sharing the same
4131
+ // millisecond timestamp would collide on the base name. Append a short
4132
+ // random suffix until O_EXCL accepts the write or the cap is exhausted.
4133
+ const dir = path.dirname(attFile);
4134
+ const MAX_SUFFIX_TRIES = 8;
4135
+ let written = false;
4136
+ let lastErr = null;
4137
+ for (let i = 0; i < MAX_SUFFIX_TRIES; i++) {
4138
+ const suffix = i === 0 ? "" : "-" + crypto.randomBytes(3).toString("hex");
4139
+ const candidate = path.join(dir, replayBaseName + suffix + ".json");
4140
+ try {
4141
+ fs.writeFileSync(candidate, JSON.stringify(replayBody, null, 2), { flag: "wx" });
4142
+ replayPath = candidate;
4143
+ written = true;
4144
+ break;
4145
+ } catch (e) {
4146
+ lastErr = e;
4147
+ if (!e || e.code !== "EEXIST") throw e;
4148
+ }
4149
+ }
4150
+ if (!written) throw lastErr || new Error("replay-record write: EEXIST after " + MAX_SUFFIX_TRIES + " attempts");
4151
+ replayPersisted = { ok: true, path: replayPath, sidecar_signed: true };
3764
4152
  } catch (e) {
3765
- // Non-fatal — the stdout emit is the operator's primary surface; a
4153
+ // Non-fatal — stdout emit is the operator's primary surface; a
3766
4154
  // disk-persistence failure shouldn't mask the verdict. Surface the
3767
4155
  // condition in the response body so an operator-side audit pipeline
3768
4156
  // can re-run the persist later.
3769
4157
  replayPersisted = { ok: false, error: String((e && e.message) || e) };
3770
4158
  }
4159
+ if (replayPersisted && replayPersisted.ok && replayPath) {
4160
+ // Sidecar signing is best-effort: the unsigned replay record on disk
4161
+ // is still a valid audit-trail entry. Split from the write try{} so a
4162
+ // sign-time failure doesn't mask a successful write.
4163
+ try {
4164
+ maybeSignAttestation(replayPath);
4165
+ } catch (e) {
4166
+ replayPersisted.sidecar_signed = false;
4167
+ replayPersisted.sidecar_sign_error = String((e && e.message) || e);
4168
+ }
4169
+ }
3771
4170
 
3772
4171
  emit({
3773
4172
  ok: true,
@@ -3782,7 +4181,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3782
4181
  replayed_at: replayedAt,
3783
4182
  replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3784
4183
  replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3785
- // F10: persist the sidecar verify result + the force-replay flag so the
4184
+ // Persist the sidecar verify result + the force-replay flag so the
3786
4185
  // audit trail records whether the replay was authenticated input.
3787
4186
  sidecar_verify: verify,
3788
4187
  // emit a one-token classification label alongside the
@@ -3800,8 +4199,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
3800
4199
  // 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
3801
4200
  sidecar_verify_class: sidecarVerifyClass,
3802
4201
  force_replay: forceReplay,
3803
- // KK P1-2: surface the persisted replay-record path (or persistence
3804
- // failure reason) so an auditor reading the CLI response can locate the
4202
+ // Surface the persisted replay-record path (or persistence failure
4203
+ // reason) so an auditor reading the CLI response can locate the
3805
4204
  // on-disk artifact without re-deriving the filename.
3806
4205
  replay_persisted: replayPersisted,
3807
4206
  }, pretty);
@@ -3819,7 +4218,7 @@ function classifySidecarVerify(verify) {
3819
4218
  if (verify.signed && !verify.verified) return "tampered";
3820
4219
  if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
3821
4220
  if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
3822
- // KK P1-3: algorithm-unsupported is its own class label so log scrapers /
4221
+ // `algorithm-unsupported` is its own class label so log scrapers /
3823
4222
  // dashboards can filter downgrade-bait events without parsing the reason.
3824
4223
  if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
3825
4224
  if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
@@ -3851,14 +4250,18 @@ function cmdAttest(runner, args, runOpts, pretty) {
3851
4250
  return cmdListAttestations(runner, args, runOpts, pretty);
3852
4251
  }
3853
4252
  if (!sessionId) {
3854
- return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
3855
- }
3856
- // R-F7: distinguish "validation rejected" from "valid format but not
3857
- // found". findSessionDir() returns null for BOTH (regex-rejected ids
3858
- // collapse to the "no session dir" message), which gives operators a
3859
- // misleading error — a string with `..` or `/` looks to them like an
3860
- // existing-session lookup that failed, not a refusal. Call the same
3861
- // validator up front; emit its specific message when it throws.
4253
+ return emitError(
4254
+ `attest ${subverb}: missing <session-id> positional argument. Inventory prior sessions with \`exceptd attest list\`; or pass \`--latest\` to operate on the most recent.`,
4255
+ { verb: `attest ${subverb}` },
4256
+ pretty
4257
+ );
4258
+ }
4259
+ // Distinguish "validation rejected" from "valid format but not found".
4260
+ // findSessionDir() returns null for BOTH (regex-rejected ids collapse to
4261
+ // the "no session dir" message), which gives operators a misleading
4262
+ // error — a string with `..` or `/` looks to them like an existing-
4263
+ // session lookup that failed, not a refusal. Call the same validator
4264
+ // up front; emit its specific message when it throws.
3862
4265
  try { validateSessionIdForRead(sessionId); }
3863
4266
  catch (e) {
3864
4267
  return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
@@ -3869,13 +4272,24 @@ function cmdAttest(runner, args, runOpts, pretty) {
3869
4272
  }
3870
4273
 
3871
4274
  const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
3872
- const attestations = files.map(f => {
3873
- try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
3874
- catch { return null; }
3875
- }).filter(Boolean);
4275
+ // Partition session-dir JSON files by parsed `kind` field. Replay records
4276
+ // (written by `cmdReattest`) live alongside attestations under the same
4277
+ // session directory but represent audit-trail entries, not separate
4278
+ // sessions. Gate on the parsed payload — not filename prefix — so a
4279
+ // renamed file cannot smuggle a replay into the attestations[] list.
4280
+ const attestations = [];
4281
+ const replays = [];
4282
+ for (const f of files) {
4283
+ let parsed;
4284
+ try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
4285
+ catch { continue; }
4286
+ if (!parsed) continue;
4287
+ if (parsed.kind === "replay") replays.push(parsed);
4288
+ else attestations.push(parsed);
4289
+ }
3876
4290
 
3877
4291
  if (subverb === "show") {
3878
- emit({ session_id: sessionId, attestations }, pretty);
4292
+ emit({ session_id: sessionId, attestations, attestation_replays: replays }, pretty);
3879
4293
  return;
3880
4294
  }
3881
4295
 
@@ -3890,10 +4304,37 @@ function cmdAttest(runner, args, runOpts, pretty) {
3890
4304
  return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
3891
4305
  }
3892
4306
  const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
3893
- if (otherFiles.length === 0) {
4307
+ // Pick the comparison target deterministically:
4308
+ // 1. Prefer attestation.json (the canonical write-path filename).
4309
+ // 2. Otherwise, walk every non-replay JSON in the dir, sort by
4310
+ // parsed.captured_at descending, and take the newest.
4311
+ // 3. Replay records (kind === "replay") are audit-trail entries,
4312
+ // not attestations — skip them so a replay file sorted ahead of
4313
+ // attestation.json cannot shadow the real attestation in the
4314
+ // diff.
4315
+ let other = null;
4316
+ const otherAttestationPath = path.join(otherDir, "attestation.json");
4317
+ if (fs.existsSync(otherAttestationPath)) {
4318
+ try {
4319
+ const parsed = JSON.parse(fs.readFileSync(otherAttestationPath, "utf8"));
4320
+ if (parsed && parsed.kind !== "replay") other = parsed;
4321
+ } catch { /* fall through to scan */ }
4322
+ }
4323
+ if (!other) {
4324
+ const candidates = [];
4325
+ for (const f of otherFiles) {
4326
+ try {
4327
+ const parsed = JSON.parse(fs.readFileSync(path.join(otherDir, f), "utf8"));
4328
+ if (!parsed || parsed.kind === "replay") continue;
4329
+ candidates.push(parsed);
4330
+ } catch { /* skip malformed */ }
4331
+ }
4332
+ candidates.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
4333
+ other = candidates[0] || null;
4334
+ }
4335
+ if (!other) {
3894
4336
  return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
3895
4337
  }
3896
- const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
3897
4338
  const self = attestations[0];
3898
4339
  emit({
3899
4340
  verb: "attest diff",
@@ -3954,15 +4395,14 @@ function cmdAttest(runner, args, runOpts, pretty) {
3954
4395
  // tampered attestation.json and overwrote .sig with the unsigned stub).
3955
4396
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3956
4397
  const hasPrivKey = fs.existsSync(privKeyPath);
3957
- const results = files.map(f => {
4398
+
4399
+ // Sidecar-verify helper shared by both the attestations[] and
4400
+ // replay-records[] partitions. Centralising the per-file verify
4401
+ // logic means a future tamper-class addition lands in one place
4402
+ // instead of two parallel branches.
4403
+ const verifySidecar = (f) => {
3958
4404
  const sigPath = path.join(dir, f + ".sig");
3959
4405
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
3960
- // wrap JSON.parse so a corrupt sidecar surfaces as a
3961
- // structured tamper-class result (signed:false, verified:false,
3962
- // tamper_class:"sidecar-corrupt") rather than throwing into the outer
3963
- // dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
3964
- // generic exit-1 with no `results` array — operators piping through
3965
- // `set -e` saw "command failed" with no tamper signal.
3966
4406
  let sigDoc;
3967
4407
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
3968
4408
  catch (e) {
@@ -3975,7 +4415,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
3975
4415
  };
3976
4416
  }
3977
4417
  if (sigDoc.algorithm === "unsigned") {
3978
- // substitution detection.
3979
4418
  if (hasPrivKey) {
3980
4419
  return {
3981
4420
  file: f,
@@ -3987,11 +4426,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
3987
4426
  }
3988
4427
  return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3989
4428
  }
3990
- // KK P1-3: strict algorithm check (mirrors verifyAttestationSidecar).
3991
- // Anything that isn't exactly "Ed25519" or "unsigned" is refused as
3992
- // tamper-class. Pre-fix null / "RSA-PSS" / arrays fell through to
3993
- // crypto.verify with Ed25519 defaults, producing either an opaque
3994
- // verify-throw or a downgrade-bait acceptance path.
3995
4429
  if (sigDoc.algorithm !== "Ed25519") {
3996
4430
  return {
3997
4431
  file: f,
@@ -4002,8 +4436,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
4002
4436
  };
4003
4437
  }
4004
4438
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
4005
- // C: normalize before crypto.verify — mirrors the signer
4006
- // path so the verify pair is byte-stable across CRLF / BOM churn.
4007
4439
  const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
4008
4440
  const content = normalizeAttestationBytes(rawContent);
4009
4441
  try {
@@ -4014,33 +4446,58 @@ function cmdAttest(runner, args, runOpts, pretty) {
4014
4446
  } catch (e) {
4015
4447
  return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
4016
4448
  }
4017
- });
4018
- // R-F1: when ANY result is signed-but-failed-verify, surface ok:false
4019
- // AND set exit 6 for parity with cmdReattest's TAMPERED code. Pre-fix,
4020
- // `attest verify` emitted {verb, session_id, results} without ok:false
4021
- // and exited 0 operators piping through `set -e` saw no failure
4022
- // signal even when an attestation had been forged. emit()'s ok:false
4023
- // exitCode = 1 auto-promotion would stop at 1; tamper is distinct
4024
- // from generic failure, so explicitly raise to 6 (cmdReattest's code).
4025
- //
4026
- // 2: extend the tamper predicate to cover the new
4027
- // tamper_class variants. Pre-fix the predicate was `r.signed && !r.verified`
4028
- // which missed (a) corrupt-JSON sidecars (signed:false) and (b) "unsigned"
4029
- // sidecar substitution on hosts with a private key (signed:false). Both
4030
- // are tamper-class events and must promote to exit 6.
4031
- const tampered = results.some(r =>
4449
+ };
4450
+
4451
+ // Partition session-dir files by the parsed `kind` field so the verify
4452
+ // output cleanly separates attestations from replay records. Mixing
4453
+ // both into a single `results` array let a replay-record tamper event
4454
+ // promote exit 6 against the operator's expectation that the
4455
+ // attestation itself was the integrity-critical artifact. With the
4456
+ // partition: attestation tamper exit 6 (operator must investigate);
4457
+ // replay-record tamper → audit-trail warning only (exit stays 0 so
4458
+ // CI gates don't fail on a corrupted audit log they can simply
4459
+ // regenerate via `reattest`).
4460
+ const attResults = [];
4461
+ const replayResults = [];
4462
+ for (const f of files) {
4463
+ let parsed = null;
4464
+ try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
4465
+ catch { /* unparseable JSON — treat as attestation so tamper detection still surfaces */ }
4466
+ const verdict = verifySidecar(f);
4467
+ if (parsed && parsed.kind === "replay") {
4468
+ replayResults.push(Object.assign({ replayed_at: parsed.replayed_at || null }, verdict));
4469
+ } else {
4470
+ attResults.push(Object.assign({ captured_at: parsed && parsed.captured_at || null }, verdict));
4471
+ }
4472
+ }
4473
+ // Deterministic ordering so the output diffs cleanly across runs.
4474
+ attResults.sort((a, b) => (a.captured_at || "").localeCompare(b.captured_at || ""));
4475
+ replayResults.sort((a, b) => (a.replayed_at || "").localeCompare(b.replayed_at || ""));
4476
+
4477
+ const tamperPredicate = (r) =>
4032
4478
  (r.signed && !r.verified)
4033
4479
  || r.tamper_class === "sidecar-corrupt"
4034
4480
  || r.tamper_class === "unsigned-substitution"
4035
- // KK P1-3: a sidecar whose algorithm field is not "Ed25519" or
4036
- // "unsigned" is a downgrade-bait substitution; promote to exit 6.
4037
- || r.tamper_class === "algorithm-unsupported"
4038
- );
4039
- const body = { verb: "attest verify", session_id: sessionId, results };
4040
- if (tampered) {
4481
+ || r.tamper_class === "algorithm-unsupported";
4482
+ const attTampered = attResults.some(tamperPredicate);
4483
+ const replayTampered = replayResults.some(tamperPredicate);
4484
+
4485
+ const body = {
4486
+ verb: "attest verify",
4487
+ session_id: sessionId,
4488
+ results: attResults,
4489
+ replay_results: replayResults,
4490
+ };
4491
+ if (attTampered) {
4041
4492
  body.ok = false;
4042
4493
  body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
4043
- process.exitCode = 6;
4494
+ process.exitCode = EXIT_CODES.TAMPERED;
4495
+ } else if (replayTampered) {
4496
+ // Replay-record tamper is an audit-trail signal but not an
4497
+ // attestation-integrity violation; surface a warning so operators
4498
+ // see the corruption without promoting the exit code.
4499
+ body.replay_tamper = true;
4500
+ body.warnings = ["one or more replay records failed Ed25519 verification — audit-trail corruption suspected, regenerate via reattest"];
4044
4501
  }
4045
4502
  emit(body, pretty);
4046
4503
  return;
@@ -4442,6 +4899,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4442
4899
  const wantJson = !!args.json || !!args.pretty;
4443
4900
  const indent = !!args.pretty;
4444
4901
 
4902
+ // `doctor --exit-codes` dumps the canonical exit-code table as JSON so
4903
+ // operator-facing docs cannot drift from runtime behavior. Short-circuit
4904
+ // before the regular health checks since the dump is informational.
4905
+ if (args["exit-codes"]) {
4906
+ emit({ verb: "doctor", exit_codes: listExitCodes() }, pretty);
4907
+ return;
4908
+ }
4909
+
4445
4910
  // Selective subchecks. If any of the four flags is passed, run only those.
4446
4911
  // If none are passed, run all four plus signing-status.
4447
4912
  const onlySigs = !!args.signatures;
@@ -4651,6 +5116,17 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4651
5116
  // workflows aren't disturbed. Routed through a child process to keep
4652
5117
  // cmdDoctor synchronous + bound the network timeout cleanly.
4653
5118
  if (args["registry-check"]) {
5119
+ // Refuse network egress when air-gap mode is active. Surface as a
5120
+ // skipped check (informational), not an error — the operator opted
5121
+ // into air-gap and would otherwise see a confusing network-error
5122
+ // result from the upstream-check probe.
5123
+ if (runOpts && runOpts.airGap) {
5124
+ checks.registry = {
5125
+ ok: null,
5126
+ skipped: "air-gap",
5127
+ reason: "registry probe disabled in air-gap mode",
5128
+ };
5129
+ } else {
4654
5130
  try {
4655
5131
  const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
4656
5132
  const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
@@ -4678,6 +5154,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4678
5154
  } catch (e) {
4679
5155
  checks.registry = { ok: false, severity: "warn", error: e.message };
4680
5156
  }
5157
+ }
4681
5158
  }
4682
5159
 
4683
5160
  // Walk every check and split: errors (severity error/missing/fail) vs warnings
@@ -4736,10 +5213,15 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4736
5213
  lines.push("exceptd doctor");
4737
5214
  function mark(c, render) {
4738
5215
  if (!c) return;
4739
- // Three states: ok / warn / error. Bug #61 (v0.11.2) warn must not be
4740
- // shown as ok and must count toward the summary so the bottom line
4741
- // matches the visible icons above.
4742
- const icon = c.ok && c.severity !== "warn" ? "[ok]" : (c.severity === "warn" ? "[!! warn]" : "[!! fail]");
5216
+ // Four states: ok / warn / error / skipped. `skipped` is informational
5217
+ // (e.g. air-gap mode disabled the network probe) and renders as
5218
+ // [info] so it doesn't read like a failure to operators scanning the
5219
+ // checklist. Three pre-existing states retained.
5220
+ let icon;
5221
+ if (c.skipped) icon = "[info]";
5222
+ else if (c.ok && c.severity !== "warn") icon = "[ok]";
5223
+ else if (c.severity === "warn") icon = "[!! warn]";
5224
+ else icon = "[!! fail]";
4743
5225
  lines.push(` ${icon} ${render(c)}`);
4744
5226
  }
4745
5227
  mark(checks.signatures, c =>
@@ -4836,8 +5318,9 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4836
5318
  // Prior behavior silently accepted any string and lexically compared to
4837
5319
  // captured_at, producing 0-result or full-result depending on the string.
4838
5320
  if (args.since != null) {
4839
- // R-F10: regex BEFORE Date.parse — bare integers like "99" would
4840
- // otherwise parse as the year 1999 and silently filter wrong eras.
5321
+ // ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
5322
+ // would otherwise parse as the year 1999 and silently filter wrong
5323
+ // eras.
4841
5324
  const sinceErr = validateIsoSince(args.since);
4842
5325
  if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
4843
5326
  }
@@ -4858,6 +5341,11 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4858
5341
  for (const f of files) {
4859
5342
  try {
4860
5343
  const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
5344
+ // replay-<isoZ>.json records share the session dir with
5345
+ // attestation.json but are not separate sessions. Gate on the
5346
+ // parsed `kind` field rather than filename so a rename cannot
5347
+ // smuggle a replay record into the listing.
5348
+ if (j && j.kind === "replay") continue;
4861
5349
  // v0.12.14: normalized array-set filter (see top of fn).
4862
5350
  if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
4863
5351
  if (args.since && (j.captured_at || "") < args.since) continue;
@@ -4919,12 +5407,13 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4919
5407
  if (!playbookId) {
4920
5408
  return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
4921
5409
  }
5410
+ if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
4922
5411
  let pb;
4923
5412
  try { pb = runner.loadPlaybook(playbookId); }
4924
5413
  catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
4925
5414
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
4926
5415
  if (!directiveId) {
4927
- return emitError(`ai-run: playbook ${playbookId} has no directives.`, null, pretty);
5416
+ return refuseNoDirectives("ai-run", playbookId, pretty);
4928
5417
  }
4929
5418
 
4930
5419
  // Compute the informational phases up front — both stream and no-stream
@@ -4935,7 +5424,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4935
5424
  directPhase = runner.direct(playbookId, directiveId);
4936
5425
  lookPhase = runner.look(playbookId, directiveId, runOpts);
4937
5426
  } catch (e) {
4938
- // v0.12.12 (T8): process.exit(1) immediately after a stdout write can
5427
+ // process.exit(1) immediately after a stdout write can
4939
5428
  // truncate buffered output under piped consumers (same class as v0.11.10
4940
5429
  // #100). Use exitCode+return so the JSONL error frame drains. Also write
4941
5430
  // the framed error event so the stdout-only JSONL contract holds — host
@@ -4984,7 +5473,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4984
5473
  try { payload = readEvidence(args.evidence); }
4985
5474
  catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
4986
5475
  } else if (hasReadableStdin()) {
4987
- // EE P1-7: hasReadableStdin() probes via fstat before falling into
5476
+ // hasReadableStdin() probes via fstat before falling into
4988
5477
  // readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
4989
5478
  // size===0) would otherwise hang here.
4990
5479
  // Drain stdin for any evidence event.
@@ -5018,7 +5507,11 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5018
5507
  try {
5019
5508
  result = runner.run(playbookId, directiveId, submission, runOpts);
5020
5509
  } catch (e) {
5021
- return emitError(`ai-run: runner threw: ${e.message}`, { playbook: playbookId }, pretty);
5510
+ return emitError(
5511
+ `ai-run: internal error (${e && e.message ? e.message : String(e)}). Re-run with --pretty for context; file at https://github.com/blamejs/exceptd-skills/issues if reproducible.`,
5512
+ { playbook: playbookId, verb: "ai-run" },
5513
+ pretty
5514
+ );
5022
5515
  }
5023
5516
  if (!result || result.ok === false) {
5024
5517
  // v0.12.12: same exit-after-write anti-pattern as the pre-stream
@@ -5056,21 +5549,26 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5056
5549
  if (!persistResult.ok && !args["force-overwrite"]) {
5057
5550
  // Collision without --force-overwrite. AI agents typically pass
5058
5551
  // unique session ids each run, so this path is rare but surface
5059
- // it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
5060
- // exit 8 set by persistAttestation when --force-overwrite hit the
5061
- // lockfile race don't clobber with exit 3.
5552
+ // it cleanly via the same JSONL contract. Three exit-code classes
5553
+ // (LOCK_CONTENTION / STORAGE_EXHAUSTED / SESSION_ID_COLLISION) so
5554
+ // a host-AI driver can branch on remediation without parsing the
5555
+ // reason string.
5062
5556
  const eventBody = {
5063
5557
  event: "error", reason: persistResult.error,
5064
5558
  existing_attestation: persistResult.existingPath,
5065
5559
  };
5066
5560
  if (persistResult.lock_contention) {
5067
5561
  eventBody.lock_contention = true;
5068
- eventBody.exit_code = 8;
5562
+ eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
5069
5563
  }
5070
- process.stdout.write(JSON.stringify(eventBody) + "\n");
5071
- if (!persistResult.lock_contention) {
5072
- process.exitCode = 3;
5564
+ if (persistResult.storage_exhausted) {
5565
+ eventBody.storage_exhausted = true;
5566
+ eventBody.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
5073
5567
  }
5568
+ process.stdout.write(JSON.stringify(eventBody) + "\n");
5569
+ if (persistResult.lock_contention) process.exitCode = EXIT_CODES.LOCK_CONTENTION;
5570
+ else if (persistResult.storage_exhausted) process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
5571
+ else process.exitCode = EXIT_CODES.SESSION_ID_COLLISION;
5074
5572
  return;
5075
5573
  }
5076
5574
  }
@@ -5174,12 +5672,18 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5174
5672
  existing_attestation: persistResult.existingPath };
5175
5673
  if (persistResult.lock_contention) {
5176
5674
  eventBody.lock_contention = true;
5177
- eventBody.exit_code = 8;
5675
+ eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
5178
5676
  writeLine(eventBody);
5179
- return finish(8);
5677
+ return finish(EXIT_CODES.LOCK_CONTENTION);
5678
+ }
5679
+ if (persistResult.storage_exhausted) {
5680
+ eventBody.storage_exhausted = true;
5681
+ eventBody.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
5682
+ writeLine(eventBody);
5683
+ return finish(EXIT_CODES.STORAGE_EXHAUSTED);
5180
5684
  }
5181
5685
  writeLine(eventBody);
5182
- return finish(3);
5686
+ return finish(EXIT_CODES.SESSION_ID_COLLISION);
5183
5687
  }
5184
5688
  }
5185
5689
  writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
@@ -5478,9 +5982,9 @@ function cmdCi(runner, args, runOpts, pretty) {
5478
5982
  const results = [];
5479
5983
  let fail = false;
5480
5984
  let failReasons = [];
5481
- // F18: track jurisdiction-clock signals separately from generic FAIL so the
5482
- // exit code can distinguish "detected/escalated" (2) from "regulatory clock
5483
- // running, operator must notify" (5). Pre-fix the two collapsed into exit 2.
5985
+ // Track jurisdiction-clock signals separately from generic FAIL so the
5986
+ // exit code can distinguish "detected/escalated" (2) from "regulatory
5987
+ // clock running, operator must notify" (5).
5484
5988
  let clockStartedFail = false;
5485
5989
  let clockStartedReasons = [];
5486
5990
 
@@ -5534,11 +6038,10 @@ function cmdCi(runner, args, runOpts, pretty) {
5534
6038
  failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
5535
6039
  }
5536
6040
  if (blockOnClock && clockStarted) {
5537
- // F18: separate "clock started" from generic FAIL. Pre-fix this collapsed
5538
- // into exit 2 (FAIL), so operators couldn't distinguish "playbook
5539
- // detected" from "regulatory clock running." Tracked separately and
5540
- // exit 5 (CLOCK_STARTED) is selected below, taking precedence over
5541
- // FAIL but not BLOCKED.
6041
+ // Separate "clock started" from generic FAIL: exit 5 (CLOCK_STARTED)
6042
+ // is selected below, taking precedence over FAIL but not BLOCKED, so
6043
+ // operators can distinguish "playbook detected" from "regulatory
6044
+ // clock running."
5542
6045
  clockStartedFail = true;
5543
6046
  clockStartedReasons.push(`${id}: jurisdiction clock started`);
5544
6047
  }
@@ -5558,7 +6061,7 @@ function cmdCi(runner, args, runOpts, pretty) {
5558
6061
  const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
5559
6062
  const totalForVerdict = results.length;
5560
6063
  const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
5561
- // F18: precedence — BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
6064
+ // Precedence: BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
5562
6065
  // CLOCK_STARTED outranks FAIL because the operator explicitly opted into
5563
6066
  // the clock gate (--block-on-jurisdiction-clock); when that gate fires,
5564
6067
  // they want the regulatory-deadline signal even if a detected finding
@@ -5614,11 +6117,11 @@ function cmdCi(runner, args, runOpts, pretty) {
5614
6117
  .filter(n => n && n.clock_started_at != null).length,
5615
6118
  framework_gap_rollup: frameworkGapRollup,
5616
6119
  framework_gap_count: frameworkGapRollup.length,
5617
- // F13: dedupe jurisdiction-clock notifications across playbooks; see
5618
- // buildJurisdictionClockRollup. Multi-playbook ci runs were producing
5619
- // one notification entry per contributing playbook (often 8+) when a
5620
- // single notification per (jurisdiction, regulation, obligation,
5621
- // window) was the right shape.
6120
+ // Dedupe jurisdiction-clock notifications across playbooks; see
6121
+ // buildJurisdictionClockRollup. Without this, multi-playbook ci runs
6122
+ // produce one notification entry per contributing playbook (often 8+)
6123
+ // when a single notification per (jurisdiction, regulation,
6124
+ // obligation, window) is the right shape.
5622
6125
  jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
5623
6126
  verdict: computedVerdict,
5624
6127
  fail_reasons: failReasons,
@@ -5653,9 +6156,14 @@ function cmdCi(runner, args, runOpts, pretty) {
5653
6156
  emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
5654
6157
  } else if (fmt && fmt !== "json") {
5655
6158
  // v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
5656
- // v0.12.14: exitCode + return; matches the emitError class fix.
5657
- process.stderr.write(JSON.stringify({ ok: false, error: `ci: --format "${fmt}" not in accepted set ["summary","markdown","csaf-2.0","sarif","openvex","json"].`, verb: "ci" }) + "\n");
5658
- process.exitCode = 2;
6159
+ // Route through emitError so the body propagates exit codes via the
6160
+ // emit() ok:false contract. ci-format-typo is operator-decision class
6161
+ // (GENERIC_FAILURE), not DETECTED_ESCALATE.
6162
+ emitError(
6163
+ `ci: --format "${fmt}" not in accepted set ["summary","markdown","csaf-2.0","sarif","openvex","json"].`,
6164
+ { verb: "ci" },
6165
+ pretty
6166
+ );
5659
6167
  return;
5660
6168
  } else {
5661
6169
  emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
@@ -5678,30 +6186,30 @@ function cmdCi(runner, args, runOpts, pretty) {
5678
6186
  // actually evaluate signals, so it can't be a true detection.
5679
6187
  if (summary.blocked > 0) {
5680
6188
  const blockedReasons = failReasons.filter(r => r.includes("blocked"));
5681
- process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit 4. Reasons:\n ${blockedReasons.join("\n ")}\n`);
5682
- process.exitCode = 4;
6189
+ process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit ${EXIT_CODES.BLOCKED}. Reasons:\n ${blockedReasons.join("\n ")}\n`);
6190
+ process.exitCode = EXIT_CODES.BLOCKED;
5683
6191
  return;
5684
6192
  }
5685
- // F18: precedence BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
6193
+ // Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
5686
6194
  // --block-on-jurisdiction-clock; when a clock fires, that's the gate
5687
6195
  // result they want to see at the exit-code layer. Per-playbook detected
5688
6196
  // findings remain in the body for them to investigate.
5689
6197
  if (clockStartedFail) {
5690
- process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit 5.\n`);
5691
- process.exitCode = 5;
6198
+ process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit ${EXIT_CODES.JURISDICTION_CLOCK_STARTED}.\n`);
6199
+ process.exitCode = EXIT_CODES.JURISDICTION_CLOCK_STARTED;
5692
6200
  return;
5693
6201
  }
5694
6202
  if (fail) {
5695
6203
  process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
5696
6204
  // v0.11.11: exitCode + return so emit()'s stdout flushes.
5697
- process.exitCode = 2;
6205
+ process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
5698
6206
  return;
5699
6207
  }
5700
6208
  const suppliedEvidence = args.evidence || args["evidence-dir"];
5701
6209
  const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
5702
6210
  if (!suppliedEvidence && allInconclusive) {
5703
- process.stderr.write(`[exceptd ci] WARN: no --evidence supplied and all ${summary.total} playbook(s) returned inconclusive. CI exit 3 = "ran but never had real data." Pass --evidence <file> or --evidence-dir <dir> for a real gate.\n`);
5704
- process.exitCode = 3;
6211
+ process.stderr.write(`[exceptd ci] WARN: no --evidence supplied and all ${summary.total} playbook(s) returned inconclusive. CI exit ${EXIT_CODES.RAN_NO_EVIDENCE} = "ran but never had real data." Pass --evidence <file> or --evidence-dir <dir> for a real gate.\n`);
6212
+ process.exitCode = EXIT_CODES.RAN_NO_EVIDENCE;
5705
6213
  }
5706
6214
  }
5707
6215