@blamejs/exceptd-skills 0.12.23 → 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.
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
@@ -277,9 +286,9 @@ v0.12.0 canonical surface
277
286
 
278
287
  ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
279
288
  2 detected/escalate, 3 ran-but-no-evidence,
280
- 4 blocked (ok:false), 5 jurisdiction clock started,
281
- 6 TAMPERED (sidecar verification failed),
282
- 8 LOCK_CONTENTION (concurrent playbook lock held).
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.)
283
292
  --all | --scope <type> | (auto-detect)
284
293
  --max-rwep <n> cap below playbook default
285
294
  --block-on-jurisdiction-clock
@@ -355,6 +364,8 @@ Examples:
355
364
  exceptd ci --scope code --max-rwep 70 # gate every code playbook
356
365
  exceptd ask "I think someone replaced npm packages" # natural-language route
357
366
 
367
+ Unknown verbs exit 2 with a structured ok:false body on stderr.
368
+
358
369
  Full documentation: ${PKG_ROOT}/README.md
359
370
  Project rules: ${PKG_ROOT}/AGENTS.md
360
371
  `);
@@ -873,6 +884,10 @@ function detectVexShape(doc) {
873
884
  }
874
885
 
875
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}`);
876
891
  const pb = runner.loadPlaybook(playbookId);
877
892
  if (!pb.directives || !pb.directives.length) {
878
893
  throw new Error(`Playbook ${playbookId} has no directives.`);
@@ -898,7 +913,9 @@ function dispatchPlaybook(cmd, argv) {
898
913
  // gate alongside --signatures. doctor --registry-check + --signatures
899
914
  // were already accepted; explicit registration removes the silent
900
915
  // "unknown bool flag" surface in parseArgs.
901
- "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"],
902
919
  multi: ["playbook", "format"],
903
920
  });
904
921
  // v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
@@ -915,34 +932,115 @@ function dispatchPlaybook(cmd, argv) {
915
932
  // Hoist into module-level state so emit() can read it without plumbing.
916
933
  global.__exceptdWantJson = args._jsonMode;
917
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
+ }
918
1009
  const runOpts = {
919
- 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",
920
1015
  forceStale: !!args["force-stale"],
921
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
+ }
922
1028
  if (args["session-id"]) {
923
- // v0.12.12: --session-id is a filesystem path component (resolves to
1029
+ // --session-id is a filesystem path component (resolves to
924
1030
  // .exceptd/attestations/<id>/attestation.json). Operator-supplied input
925
- // with `..` or path separators escapes the attestation root. Validate
926
- // 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.
927
1035
  const sid = args["session-id"];
928
- if (typeof sid !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(sid)) {
1036
+ const r = validateIdComponent(sid, "session");
1037
+ if (!r.ok) {
929
1038
  return emitError(
930
- "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.`,
931
1040
  { provided: typeof sid === "string" ? sid.slice(0, 80) : typeof sid },
932
1041
  pretty
933
1042
  );
934
1043
  }
935
- // The character-class regex accepts any all-dots string (`.`, `..`,
936
- // `...`); each resolves into or above the attestation root. Refuse
937
- // them explicitly so the attestation is never written outside the
938
- // intended directory.
939
- if (/^\.+$/.test(sid)) {
940
- return emitError(
941
- "run: --session-id cannot consist entirely of dots (rejected: '.', '..', etc.).",
942
- { provided: sid },
943
- pretty
944
- );
945
- }
946
1044
  runOpts.session_id = sid;
947
1045
  }
948
1046
  if (args["attestation-root"]) {
@@ -954,13 +1052,28 @@ function dispatchPlaybook(cmd, argv) {
954
1052
  if (typeof ar !== "string" || ar.length === 0) {
955
1053
  return emitError("run: --attestation-root must be a non-empty string.", { provided: typeof ar }, pretty);
956
1054
  }
957
- if (ar.split(/[\\/]/).some(seg => seg === "..")) {
1055
+ const arSegments = ar.split(/[\\/]/);
1056
+ if (arSegments.some(seg => seg === "..")) {
958
1057
  return emitError(
959
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.",
960
1059
  { provided: ar.slice(0, 200) },
961
1060
  pretty
962
1061
  );
963
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
+ }
964
1077
  runOpts.attestationRoot = path.resolve(ar);
965
1078
  }
966
1079
  if (args["session-key"]) {
@@ -1221,7 +1334,15 @@ function dispatchPlaybook(cmd, argv) {
1221
1334
  return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
1222
1335
  }
1223
1336
  }
1224
- 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
+ );
1225
1346
  }
1226
1347
  }
1227
1348
 
@@ -1428,19 +1549,38 @@ Exit codes (per-verb, post-run):
1428
1549
  3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
1429
1550
  4 Blocked Result returned ok:false (preflight halt).
1430
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.
1431
1554
  8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
1432
1555
  attestation lock after the bounded retry budget
1433
1556
  (~1-2s). Distinct from 1 so callers can retry the
1434
1557
  operation rather than treat it as a hard failure.
1435
1558
  Surfaces as body.lock_contention=true,
1436
1559
  body.exit_code=8.
1437
- 6-7 reserved (6=TAMPERED on attest verifier; 7 unused)`,
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).`,
1438
1568
  ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
1439
1569
 
1440
1570
  Flags:
1441
1571
  --domain <id> Playbook ID (overrides submission.playbook_id).
1442
1572
  --directive <id> Directive ID (overrides submission.directive_id).
1443
1573
  --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
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.
1444
1584
  --csaf-status <s> CSAF tracking.status for the close.evidence_package
1445
1585
  bundle. One of: draft | interim (default) | final.
1446
1586
  'final' commits to CSAF §3.1.11.3.5.1 immutability —
@@ -1449,7 +1589,10 @@ Flags:
1449
1589
  CSAF document.publisher.namespace (§3.1.7.4). The
1450
1590
  operator's organisation URL, NOT the tooling vendor.
1451
1591
  Must be an http://… or https://… URL, ≤256 chars.
1452
- --pretty Indented JSON output.`,
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.`,
1453
1596
  reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
1454
1597
 
1455
1598
  Args / flags:
@@ -1475,20 +1618,26 @@ Lists every attestation under .exceptd/attestations/<session_id>/, sorted
1475
1618
  newest-first, with truncated evidence_hash + capture timestamp + file path.`,
1476
1619
  attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
1477
1620
 
1478
- Subverbs:
1621
+ Subverbs (list | show | export | verify | diff):
1479
1622
  attest show <sid> Emit the full (unredacted) attestation.
1480
1623
  attest list Inventory every prior attestation under
1481
1624
  ~/.exceptd/attestations/ (or EXCEPTD_HOME when set).
1482
- Filter with --playbook <id> or --since <ISO>. Newest
1483
- first; truncated evidence_hash + capture timestamp +
1484
- 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.
1485
1629
  attest export <sid> Emit redacted JSON suitable for audit submission.
1486
1630
  Strips raw artifact values; preserves evidence_hash,
1487
1631
  signature, classification, RWEP, remediation choice.
1488
- --format <csaf|sarif|openvex> wraps the export in the
1489
- 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).
1490
1635
  attest verify <sid> Verify .sig sidecar against keys/public.pem.
1491
- 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).
1492
1641
  attest diff <sid> Diff <sid> against the most-recent prior attestation
1493
1642
  for the same playbook, or against --against <other-sid>
1494
1643
  for an explicit pair. Reports unchanged | drifted |
@@ -1499,7 +1648,8 @@ All subverbs honor --pretty for indented JSON output.
1499
1648
  Exit codes (attest verify):
1500
1649
  0 verification succeeded
1501
1650
  1 generic failure
1502
- 6 TAMPERED (sidecar or signature mismatch)`,
1651
+ 6 TAMPERED (sidecar or signature mismatch on an attestation; replay-record
1652
+ tamper warns but exits 0)`,
1503
1653
  discover: `discover — context-aware playbook recommender (v0.11.0).
1504
1654
 
1505
1655
  Replaces: scan + dispatch + recommend.
@@ -1514,7 +1664,11 @@ Flags:
1514
1664
  --json Emit JSON (default is human-readable text).
1515
1665
  --pretty Indented JSON output (implies --json).
1516
1666
 
1517
- 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.`,
1518
1672
  doctor: `doctor — one-shot health check (v0.11.0).
1519
1673
 
1520
1674
  Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
@@ -1562,8 +1716,21 @@ Flags:
1562
1716
  CSAF document.publisher.namespace (§3.1.7.4). The
1563
1717
  operator's organisation URL, NOT the tooling vendor.
1564
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}$/).
1565
1725
  --pretty Indented JSON output (single-shot only).
1566
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
+
1567
1734
  Stdin event grammar (one JSON object per line):
1568
1735
  {"event":"evidence","payload":{"observations":{},"verdict":{}}}
1569
1736
 
@@ -1597,7 +1764,9 @@ Args / flags:
1597
1764
 
1598
1765
  Output: { verb, question, routed_to:[ids], confidence, next_step,
1599
1766
  full_match_list }. Empty match list when no token overlap — surfaces a
1600
- 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).`,
1601
1770
  ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
1602
1771
 
1603
1772
  Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
@@ -1642,13 +1811,9 @@ Exit codes:
1642
1811
  close.notification_actions entry started a
1643
1812
  regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
1644
1813
  etc.) and the operator has not acked.
1645
- 6 TAMPERED Attestation sidecar verification failed (Ed25519
1646
- signature mismatch on a prior session referenced
1647
- by the run, or the replay record's sidecar did
1648
- not verify against keys/public.pem).
1649
- 8 LOCK_CONTENTION Concurrent run holds the per-playbook attestation
1650
- lock; the bounded retry budget (~1-2s) elapsed
1651
- without acquiring it. Retry the operation.
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\`.)
1652
1817
 
1653
1818
  Output: verb, session_id, playbooks_run, summary{total, detected,
1654
1819
  max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
@@ -1740,9 +1905,22 @@ function cmdLint(runner, args, runOpts, pretty) {
1740
1905
  if (!playbookId || !evidencePath) {
1741
1906
  return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
1742
1907
  }
1908
+ if (refuseInvalidPlaybookId("lint", playbookId, pretty)) return;
1743
1909
  let pb;
1744
1910
  try { pb = runner.loadPlaybook(playbookId); }
1745
- 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
+ }
1746
1924
 
1747
1925
  let submission;
1748
1926
  try { submission = readEvidence(evidencePath); }
@@ -1881,6 +2059,7 @@ function cmdBrief(runner, args, runOpts, pretty) {
1881
2059
  return cmdPlan(runner, args, runOpts, pretty);
1882
2060
  }
1883
2061
 
2062
+ if (refuseInvalidPlaybookId("brief", playbookId, pretty)) return;
1884
2063
  const pb = runner.loadPlaybook(playbookId);
1885
2064
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
1886
2065
 
@@ -2039,6 +2218,41 @@ function validateScopeOrThrow(scope) {
2039
2218
  return scope;
2040
2219
  }
2041
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
+
2042
2256
  function filterPlaybooksByScope(runner, scope) {
2043
2257
  validateScopeOrThrow(scope);
2044
2258
  const ids = runner.listPlaybooks();
@@ -2081,27 +2295,30 @@ function detectScopes() {
2081
2295
  function cmdGovern(runner, args, runOpts, pretty) {
2082
2296
  const playbookId = args._[0];
2083
2297
  if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
2298
+ if (refuseInvalidPlaybookId("govern", playbookId, pretty)) return;
2084
2299
  const pb = runner.loadPlaybook(playbookId);
2085
2300
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2086
- if (!directiveId) return emitError(`govern: playbook ${playbookId} has no directives.`, null, pretty);
2301
+ if (!directiveId) return refuseNoDirectives("govern", playbookId, pretty);
2087
2302
  emit(runner.govern(playbookId, directiveId, runOpts), pretty);
2088
2303
  }
2089
2304
 
2090
2305
  function cmdDirect(runner, args, pretty) {
2091
2306
  const playbookId = args._[0];
2092
2307
  if (!playbookId) return emitError("direct: missing <playbookId> positional argument.", null, pretty);
2308
+ if (refuseInvalidPlaybookId("direct", playbookId, pretty)) return;
2093
2309
  const pb = runner.loadPlaybook(playbookId);
2094
2310
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2095
- if (!directiveId) return emitError(`direct: playbook ${playbookId} has no directives.`, null, pretty);
2311
+ if (!directiveId) return refuseNoDirectives("direct", playbookId, pretty);
2096
2312
  emit(runner.direct(playbookId, directiveId), pretty);
2097
2313
  }
2098
2314
 
2099
2315
  function cmdLook(runner, args, runOpts, pretty) {
2100
2316
  const playbookId = args._[0];
2101
2317
  if (!playbookId) return emitError("look: missing <playbookId> positional argument.", null, pretty);
2318
+ if (refuseInvalidPlaybookId("look", playbookId, pretty)) return;
2102
2319
  const pb = runner.loadPlaybook(playbookId);
2103
2320
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2104
- if (!directiveId) return emitError(`look: playbook ${playbookId} has no directives.`, null, pretty);
2321
+ if (!directiveId) return refuseNoDirectives("look", playbookId, pretty);
2105
2322
  emit(runner.look(playbookId, directiveId, runOpts), pretty);
2106
2323
  }
2107
2324
 
@@ -2132,16 +2349,32 @@ function cmdRun(runner, args, runOpts, pretty) {
2132
2349
  const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
2133
2350
  const unique = [...new Set(ids)];
2134
2351
  if (unique.length === 0) {
2135
- 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
+ );
2136
2368
  }
2137
2369
  return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
2138
2370
  }
2139
2371
 
2140
2372
  // Single-playbook path (existing behavior).
2141
2373
  const playbookId = positional;
2374
+ if (refuseInvalidPlaybookId("run", playbookId, pretty)) return;
2142
2375
  const pb = runner.loadPlaybook(playbookId);
2143
2376
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2144
- if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
2377
+ if (!directiveId) return refuseNoDirectives("run", playbookId, pretty);
2145
2378
 
2146
2379
  // --explain: dry-run that emits the preconditions + artifacts + indicators
2147
2380
  // + signal keys the agent would need to supply, WITHOUT running detect/
@@ -2369,31 +2602,34 @@ function cmdRun(runner, args, runOpts, pretty) {
2369
2602
  });
2370
2603
  if (!persistResult.ok) {
2371
2604
  // Session-id collision without --force-overwrite, OR --force-overwrite
2372
- // lost the lockfile race. Refuse, surface the existing path so the
2373
- // operator can decide, emit JSON to stderr matching the unified error
2374
- // shape. Exit non-zero a silent overwrite is a tamper-evidence
2375
- // violation. v0.12.14: exitCode + return instead of process.exit so
2376
- // the stderr line drains under piped CI consumers.
2377
- //
2378
- // When persistAttestation lost the lockfile race it pinned
2379
- // process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
2380
- // overwrite that with 3 — preserve the exit-8 contract callers depend
2381
- // on to distinguish lock-busy from collision.
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.
2382
2612
  const err = {
2383
2613
  ok: false,
2384
2614
  error: persistResult.error,
2385
2615
  existing_attestation: persistResult.existingPath,
2386
- 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).",
2387
2619
  verb: "run",
2388
2620
  };
2389
2621
  if (persistResult.lock_contention) {
2390
2622
  err.lock_contention = true;
2391
- err.exit_code = 8;
2623
+ err.exit_code = EXIT_CODES.LOCK_CONTENTION;
2392
2624
  }
2393
- process.stderr.write(JSON.stringify(err) + "\n");
2394
- if (!persistResult.lock_contention) {
2395
- process.exitCode = 3;
2625
+ if (persistResult.storage_exhausted) {
2626
+ err.storage_exhausted = true;
2627
+ err.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
2396
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;
2397
2633
  return;
2398
2634
  }
2399
2635
  if (persistResult.prior_session_id) {
@@ -2413,7 +2649,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2413
2649
  // --ci the legacy exit 1 is preserved (ok:false bodies are framework
2414
2650
  // signals when no CI gating is requested).
2415
2651
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
2416
- process.exitCode = args.ci ? 4 : 1;
2652
+ process.exitCode = args.ci ? EXIT_CODES.BLOCKED : EXIT_CODES.GENERIC_FAILURE;
2417
2653
  return;
2418
2654
  }
2419
2655
 
@@ -2434,11 +2670,11 @@ function cmdRun(runner, args, runOpts, pretty) {
2434
2670
  result.strict_preconditions_violated = warnIssues.map(i => ({
2435
2671
  id: i.id, kind: i.kind, message: i.message || null, on_fail: i.on_fail || null,
2436
2672
  }));
2437
- 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`);
2438
2674
  emit(result, pretty);
2439
2675
  // v0.11.11: exitCode + return so emit()'s stdout flushes (process.exit
2440
2676
  // can truncate buffered async stdout writes when piped).
2441
- process.exitCode = 1;
2677
+ process.exitCode = EXIT_CODES.GENERIC_FAILURE;
2442
2678
  return;
2443
2679
  }
2444
2680
  }
@@ -2476,9 +2712,9 @@ function cmdRun(runner, args, runOpts, pretty) {
2476
2712
  const refs = startedClocks
2477
2713
  .map(n => `${n.obligation_ref || n.jurisdiction || "?"}@${n.clock_started_at}`)
2478
2714
  .join("; ");
2479
- 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`);
2480
2716
  emit(result, pretty);
2481
- process.exitCode = 5;
2717
+ process.exitCode = EXIT_CODES.JURISDICTION_CLOCK_STARTED;
2482
2718
  return;
2483
2719
  }
2484
2720
  }
@@ -2515,12 +2751,12 @@ function cmdRun(runner, args, runOpts, pretty) {
2515
2751
  // under piped consumers (CI runners, jq, test harnesses).
2516
2752
  if (classification === "detected") {
2517
2753
  process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
2518
- process.exitCode = 2;
2754
+ process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
2519
2755
  return;
2520
2756
  }
2521
2757
  if (classification === "inconclusive" && escalate) {
2522
2758
  process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
2523
- process.exitCode = 2;
2759
+ process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
2524
2760
  return;
2525
2761
  }
2526
2762
  if (classification === "inconclusive") {
@@ -2786,8 +3022,18 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2786
3022
  // symlink/junction inside the dir, but the filter is cheap.
2787
3023
  for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
2788
3024
  const pbId = f.replace(/\.json$/, "");
2789
- if (!/^[A-Za-z0-9_.-]+$/.test(pbId)) {
2790
- 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
+ );
2791
3037
  }
2792
3038
  const entryPath = path.resolve(path.join(resolvedDir, f));
2793
3039
  if (!entryPath.startsWith(resolvedDir + path.sep)) {
@@ -2849,10 +3095,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2849
3095
 
2850
3096
  const results = [];
2851
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
+ }
2852
3107
  const pb = runner.loadPlaybook(id);
2853
3108
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
2854
3109
  if (!directiveId) {
2855
- results.push({ playbook_id: id, ok: false, error: "no directives" });
3110
+ results.push({ playbook_id: id, ok: false, error: `playbook ${id} has no directives` });
2856
3111
  continue;
2857
3112
  }
2858
3113
  const submission = bundle[id] || {};
@@ -2903,8 +3158,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2903
3158
  if (!persisted.ok) {
2904
3159
  // Multi-run collision: don't abort the whole bundle; surface in the
2905
3160
  // per-playbook result so the operator can see exactly which
2906
- // 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.
2907
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
+ }
2908
3174
  } else if (persisted.prior_session_id) {
2909
3175
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
2910
3176
  }
@@ -2943,8 +3209,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2943
3209
  // the body but exit code stayed 0 — CI gates couldn't distinguish "ran
2944
3210
  // clean" from "blocked." v0.12.8: use exitCode (not process.exit()) so
2945
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);
2946
3221
  const anyBlocked = results.some(r => r.ok === false);
2947
- 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; }
2948
3225
  }
2949
3226
 
2950
3227
  function cmdIngest(runner, args, runOpts, pretty) {
@@ -2971,11 +3248,12 @@ function cmdIngest(runner, args, runOpts, pretty) {
2971
3248
  }
2972
3249
  const playbookId = args.domain || submission.playbook_id || submission.domain;
2973
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;
2974
3252
  const pb = runner.loadPlaybook(playbookId);
2975
3253
  const directiveId = args.directive
2976
3254
  || submission.directive_id
2977
3255
  || (pb.directives[0] && pb.directives[0].id);
2978
- if (!directiveId) return emitError(`ingest: playbook ${playbookId} has no directives.`, null, pretty);
3256
+ if (!directiveId) return refuseNoDirectives("ingest", playbookId, pretty);
2979
3257
 
2980
3258
  // Strip the routing keys so the runner only sees the contract shape it expects.
2981
3259
  const cleanedSubmission = {
@@ -3022,17 +3300,24 @@ function cmdIngest(runner, args, runOpts, pretty) {
3022
3300
  filename: "attestation.json",
3023
3301
  });
3024
3302
  if (!persisted.ok) {
3025
- // Surface the collision; do not silently clobber. Preserve
3026
- // LOCK_CONTENTION exit 8 set by persistAttestation when
3027
- // --force-overwrite hit the lockfile race.
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).
3028
3307
  const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
3029
3308
  if (persisted.lock_contention) {
3030
3309
  ctx.lock_contention = true;
3031
- ctx.exit_code = 8;
3032
- process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
3033
- return;
3310
+ ctx.exit_code = EXIT_CODES.LOCK_CONTENTION;
3311
+ }
3312
+ if (persisted.storage_exhausted) {
3313
+ ctx.storage_exhausted = true;
3314
+ ctx.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
3034
3315
  }
3035
- return emitError(persisted.error, ctx, pretty);
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;
3036
3321
  }
3037
3322
  if (persisted.prior_session_id) {
3038
3323
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
@@ -3204,6 +3489,22 @@ function persistAttestation(args) {
3204
3489
  acquired = true;
3205
3490
  break;
3206
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
+ }
3207
3508
  if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
3208
3509
  let reclaimed = false;
3209
3510
  try {
@@ -3244,13 +3545,13 @@ function persistAttestation(args) {
3244
3545
  // already-non-zero value. Exit code 8 is reserved exclusively for
3245
3546
  // LOCK_CONTENTION (attestation persist); see the exit-code table in
3246
3547
  // printGlobalHelp().
3247
- process.exitCode = 8;
3548
+ process.exitCode = EXIT_CODES.LOCK_CONTENTION;
3248
3549
  return {
3249
3550
  ok: false,
3250
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.`,
3251
3552
  existingPath: path.relative(process.cwd(), filePath),
3252
3553
  lock_contention: true,
3253
- exit_code: 8,
3554
+ exit_code: EXIT_CODES.LOCK_CONTENTION,
3254
3555
  };
3255
3556
  }
3256
3557
  try {
@@ -3273,6 +3574,19 @@ function persistAttestation(args) {
3273
3574
  }
3274
3575
  }
3275
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
+ }
3276
3590
  return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
3277
3591
  }
3278
3592
  }
@@ -3384,9 +3698,13 @@ function maybeSignAttestation(filePath) {
3384
3698
  * Validation regex + root-confinement check matches persistAttestation.
3385
3699
  */
3386
3700
  function validateSessionIdForRead(sessionId) {
3387
- 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) {
3388
3706
  throw new Error(
3389
- `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}.`
3390
3708
  );
3391
3709
  }
3392
3710
  return sessionId;
@@ -3644,7 +3962,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3644
3962
  hint: "If you have inspected the attestation and the divergence is benign (e.g. you re-signed manually), pass --force-replay.",
3645
3963
  };
3646
3964
  process.stderr.write(JSON.stringify(body) + "\n");
3647
- process.exitCode = 6;
3965
+ process.exitCode = EXIT_CODES.TAMPERED;
3648
3966
  return;
3649
3967
  }
3650
3968
  if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
@@ -3671,7 +3989,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3671
3989
  hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
3672
3990
  };
3673
3991
  process.stderr.write(JSON.stringify(body) + "\n");
3674
- process.exitCode = 6;
3992
+ process.exitCode = EXIT_CODES.TAMPERED;
3675
3993
  return;
3676
3994
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3677
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`);
@@ -3695,7 +4013,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3695
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.",
3696
4014
  };
3697
4015
  process.stderr.write(JSON.stringify(body) + "\n");
3698
- process.exitCode = 6;
4016
+ process.exitCode = EXIT_CODES.TAMPERED;
3699
4017
  return;
3700
4018
  } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
3701
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`);
@@ -3725,16 +4043,32 @@ function cmdReattest(runner, args, runOpts, pretty) {
3725
4043
  // Fallback: synthesise pass-through preconditions from the playbook so the
3726
4044
  // replay isn't blocked when the operator didn't originally pass them.
3727
4045
  try {
3728
- const pb = runner.loadPlaybook(prior.playbook_id);
3729
- const synth = {};
3730
- for (const pc of (pb._meta && pb._meta.preconditions) || []) synth[pc.id] = true;
3731
- 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
+ }
3732
4057
  } catch { /* ignore */ }
3733
4058
  }
3734
4059
  const replay = runner.run(prior.playbook_id, prior.directive_id, emptySubmission, replayOpts);
3735
4060
 
3736
4061
  if (!replay || replay.ok === false) {
3737
- 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
+ );
3738
4072
  }
3739
4073
 
3740
4074
  const priorHash = prior.evidence_hash;
@@ -3916,7 +4250,11 @@ function cmdAttest(runner, args, runOpts, pretty) {
3916
4250
  return cmdListAttestations(runner, args, runOpts, pretty);
3917
4251
  }
3918
4252
  if (!sessionId) {
3919
- return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
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
+ );
3920
4258
  }
3921
4259
  // Distinguish "validation rejected" from "valid format but not found".
3922
4260
  // findSessionDir() returns null for BOTH (regex-rejected ids collapse to
@@ -3966,18 +4304,33 @@ function cmdAttest(runner, args, runOpts, pretty) {
3966
4304
  return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
3967
4305
  }
3968
4306
  const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
3969
- // Skip replay-record files: those carry `kind: 'replay'` and are
3970
- // audit-trail entries rather than attestations. Without this gate
3971
- // a replay file sorted ahead of attestation.json would shadow the
3972
- // real attestation in the diff.
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.
3973
4315
  let other = null;
3974
- for (const f of otherFiles) {
4316
+ const otherAttestationPath = path.join(otherDir, "attestation.json");
4317
+ if (fs.existsSync(otherAttestationPath)) {
3975
4318
  try {
3976
- const parsed = JSON.parse(fs.readFileSync(path.join(otherDir, f), "utf8"));
3977
- if (parsed && parsed.kind === "replay") continue;
3978
- other = parsed;
3979
- break;
3980
- } catch { /* skip malformed */ }
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;
3981
4334
  }
3982
4335
  if (!other) {
3983
4336
  return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
@@ -4042,15 +4395,14 @@ function cmdAttest(runner, args, runOpts, pretty) {
4042
4395
  // tampered attestation.json and overwrote .sig with the unsigned stub).
4043
4396
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
4044
4397
  const hasPrivKey = fs.existsSync(privKeyPath);
4045
- 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) => {
4046
4404
  const sigPath = path.join(dir, f + ".sig");
4047
4405
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
4048
- // wrap JSON.parse so a corrupt sidecar surfaces as a
4049
- // structured tamper-class result (signed:false, verified:false,
4050
- // tamper_class:"sidecar-corrupt") rather than throwing into the outer
4051
- // dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
4052
- // generic exit-1 with no `results` array — operators piping through
4053
- // `set -e` saw "command failed" with no tamper signal.
4054
4406
  let sigDoc;
4055
4407
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
4056
4408
  catch (e) {
@@ -4063,7 +4415,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
4063
4415
  };
4064
4416
  }
4065
4417
  if (sigDoc.algorithm === "unsigned") {
4066
- // substitution detection.
4067
4418
  if (hasPrivKey) {
4068
4419
  return {
4069
4420
  file: f,
@@ -4075,11 +4426,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
4075
4426
  }
4076
4427
  return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
4077
4428
  }
4078
- // Strict algorithm check (mirrors verifyAttestationSidecar). Anything
4079
- // that isn't exactly "Ed25519" or "unsigned" is refused as
4080
- // tamper-class; null / "RSA-PSS" / arrays would otherwise fall through
4081
- // to crypto.verify with Ed25519 defaults, producing either an opaque
4082
- // verify-throw or a downgrade-bait acceptance path.
4083
4429
  if (sigDoc.algorithm !== "Ed25519") {
4084
4430
  return {
4085
4431
  file: f,
@@ -4090,8 +4436,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
4090
4436
  };
4091
4437
  }
4092
4438
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
4093
- // Normalize before crypto.verify — mirrors the signer path so the
4094
- // verify pair is byte-stable across CRLF / BOM churn.
4095
4439
  const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
4096
4440
  const content = normalizeAttestationBytes(rawContent);
4097
4441
  try {
@@ -4102,30 +4446,58 @@ function cmdAttest(runner, args, runOpts, pretty) {
4102
4446
  } catch (e) {
4103
4447
  return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
4104
4448
  }
4105
- });
4106
- // When ANY result is signed-but-failed-verify, surface ok:false AND
4107
- // set exit 6 for parity with cmdReattest's TAMPERED code. The
4108
- // ok:false exitCode = 1 auto-promotion would stop at 1; tamper is
4109
- // distinct from generic failure, so explicitly raise to 6.
4110
- //
4111
- // The tamper predicate covers every tamper_class variant — bare
4112
- // `r.signed && !r.verified` would miss (a) corrupt-JSON sidecars
4113
- // (signed:false) and (b) "unsigned" sidecar substitution on hosts with
4114
- // a private key (signed:false). Both are tamper-class events and must
4115
- // promote to exit 6.
4116
- 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) =>
4117
4478
  (r.signed && !r.verified)
4118
4479
  || r.tamper_class === "sidecar-corrupt"
4119
4480
  || r.tamper_class === "unsigned-substitution"
4120
- // A sidecar whose algorithm field is not "Ed25519" or "unsigned" is
4121
- // a downgrade-bait substitution; promote to exit 6.
4122
- || r.tamper_class === "algorithm-unsupported"
4123
- );
4124
- const body = { verb: "attest verify", session_id: sessionId, results };
4125
- 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) {
4126
4492
  body.ok = false;
4127
4493
  body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
4128
- 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"];
4129
4501
  }
4130
4502
  emit(body, pretty);
4131
4503
  return;
@@ -4527,6 +4899,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4527
4899
  const wantJson = !!args.json || !!args.pretty;
4528
4900
  const indent = !!args.pretty;
4529
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
+
4530
4910
  // Selective subchecks. If any of the four flags is passed, run only those.
4531
4911
  // If none are passed, run all four plus signing-status.
4532
4912
  const onlySigs = !!args.signatures;
@@ -4736,6 +5116,17 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4736
5116
  // workflows aren't disturbed. Routed through a child process to keep
4737
5117
  // cmdDoctor synchronous + bound the network timeout cleanly.
4738
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 {
4739
5130
  try {
4740
5131
  const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
4741
5132
  const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
@@ -4763,6 +5154,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4763
5154
  } catch (e) {
4764
5155
  checks.registry = { ok: false, severity: "warn", error: e.message };
4765
5156
  }
5157
+ }
4766
5158
  }
4767
5159
 
4768
5160
  // Walk every check and split: errors (severity error/missing/fail) vs warnings
@@ -4821,10 +5213,15 @@ function cmdDoctor(runner, args, runOpts, pretty) {
4821
5213
  lines.push("exceptd doctor");
4822
5214
  function mark(c, render) {
4823
5215
  if (!c) return;
4824
- // Three states: ok / warn / error. Bug #61 (v0.11.2) warn must not be
4825
- // shown as ok and must count toward the summary so the bottom line
4826
- // matches the visible icons above.
4827
- 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]";
4828
5225
  lines.push(` ${icon} ${render(c)}`);
4829
5226
  }
4830
5227
  mark(checks.signatures, c =>
@@ -5010,12 +5407,13 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5010
5407
  if (!playbookId) {
5011
5408
  return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
5012
5409
  }
5410
+ if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
5013
5411
  let pb;
5014
5412
  try { pb = runner.loadPlaybook(playbookId); }
5015
5413
  catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
5016
5414
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
5017
5415
  if (!directiveId) {
5018
- return emitError(`ai-run: playbook ${playbookId} has no directives.`, null, pretty);
5416
+ return refuseNoDirectives("ai-run", playbookId, pretty);
5019
5417
  }
5020
5418
 
5021
5419
  // Compute the informational phases up front — both stream and no-stream
@@ -5109,7 +5507,11 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5109
5507
  try {
5110
5508
  result = runner.run(playbookId, directiveId, submission, runOpts);
5111
5509
  } catch (e) {
5112
- 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
+ );
5113
5515
  }
5114
5516
  if (!result || result.ok === false) {
5115
5517
  // v0.12.12: same exit-after-write anti-pattern as the pre-stream
@@ -5147,21 +5549,26 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5147
5549
  if (!persistResult.ok && !args["force-overwrite"]) {
5148
5550
  // Collision without --force-overwrite. AI agents typically pass
5149
5551
  // unique session ids each run, so this path is rare but surface
5150
- // it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
5151
- // exit 8 set by persistAttestation when --force-overwrite hit the
5152
- // lockfile race don't clobber with exit 3.
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.
5153
5556
  const eventBody = {
5154
5557
  event: "error", reason: persistResult.error,
5155
5558
  existing_attestation: persistResult.existingPath,
5156
5559
  };
5157
5560
  if (persistResult.lock_contention) {
5158
5561
  eventBody.lock_contention = true;
5159
- eventBody.exit_code = 8;
5562
+ eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
5160
5563
  }
5161
- process.stdout.write(JSON.stringify(eventBody) + "\n");
5162
- if (!persistResult.lock_contention) {
5163
- process.exitCode = 3;
5564
+ if (persistResult.storage_exhausted) {
5565
+ eventBody.storage_exhausted = true;
5566
+ eventBody.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
5164
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;
5165
5572
  return;
5166
5573
  }
5167
5574
  }
@@ -5265,12 +5672,18 @@ function cmdAiRun(runner, args, runOpts, pretty) {
5265
5672
  existing_attestation: persistResult.existingPath };
5266
5673
  if (persistResult.lock_contention) {
5267
5674
  eventBody.lock_contention = true;
5268
- eventBody.exit_code = 8;
5675
+ eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
5676
+ writeLine(eventBody);
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;
5269
5682
  writeLine(eventBody);
5270
- return finish(8);
5683
+ return finish(EXIT_CODES.STORAGE_EXHAUSTED);
5271
5684
  }
5272
5685
  writeLine(eventBody);
5273
- return finish(3);
5686
+ return finish(EXIT_CODES.SESSION_ID_COLLISION);
5274
5687
  }
5275
5688
  }
5276
5689
  writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
@@ -5743,9 +6156,14 @@ function cmdCi(runner, args, runOpts, pretty) {
5743
6156
  emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
5744
6157
  } else if (fmt && fmt !== "json") {
5745
6158
  // v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
5746
- // v0.12.14: exitCode + return; matches the emitError class fix.
5747
- 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");
5748
- 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
+ );
5749
6167
  return;
5750
6168
  } else {
5751
6169
  emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
@@ -5768,8 +6186,8 @@ function cmdCi(runner, args, runOpts, pretty) {
5768
6186
  // actually evaluate signals, so it can't be a true detection.
5769
6187
  if (summary.blocked > 0) {
5770
6188
  const blockedReasons = failReasons.filter(r => r.includes("blocked"));
5771
- process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit 4. Reasons:\n ${blockedReasons.join("\n ")}\n`);
5772
- 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;
5773
6191
  return;
5774
6192
  }
5775
6193
  // Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
@@ -5777,21 +6195,21 @@ function cmdCi(runner, args, runOpts, pretty) {
5777
6195
  // result they want to see at the exit-code layer. Per-playbook detected
5778
6196
  // findings remain in the body for them to investigate.
5779
6197
  if (clockStartedFail) {
5780
- process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit 5.\n`);
5781
- 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;
5782
6200
  return;
5783
6201
  }
5784
6202
  if (fail) {
5785
6203
  process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
5786
6204
  // v0.11.11: exitCode + return so emit()'s stdout flushes.
5787
- process.exitCode = 2;
6205
+ process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
5788
6206
  return;
5789
6207
  }
5790
6208
  const suppliedEvidence = args.evidence || args["evidence-dir"];
5791
6209
  const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
5792
6210
  if (!suppliedEvidence && allInconclusive) {
5793
- 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`);
5794
- 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;
5795
6213
  }
5796
6214
  }
5797
6215