@blamejs/exceptd-skills 0.12.22 → 0.12.23

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
@@ -85,14 +85,21 @@ function assertExpectedFingerprint(pubKeyPem) {
85
85
  } catch (e) {
86
86
  return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
87
87
  }
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.
88
+ // Route through the shared lib/verify loader so a BOM-prefixed pin file
89
+ // (Notepad with files.encoding=utf8bom) is tolerated identically across
90
+ // every verify site. The helper strips leading U+FEFF + ignores comment
91
+ // lines.
92
92
  const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
93
93
  const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
94
94
  if (firstLine === liveFp) return null;
95
- if (process.env.KEYS_ROTATED === "1") return null;
95
+ if (process.env.KEYS_ROTATED === "1") {
96
+ process.emitWarning(
97
+ `EXPECTED_FINGERPRINT mismatch accepted via KEYS_ROTATED=1: live=${liveFp} pin=${firstLine}. ` +
98
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
99
+ { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
100
+ );
101
+ return null;
102
+ }
96
103
  return (
97
104
  `EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
98
105
  `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
@@ -268,9 +275,11 @@ v0.12.0 canonical surface
268
275
  --registry-check (v0.11.14) opt-in: query npm registry
269
276
  for latest published version + days behind
270
277
 
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.
278
+ ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
279
+ 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).
274
283
  --all | --scope <type> | (auto-detect)
275
284
  --max-rwep <n> cap below playbook default
276
285
  --block-on-jurisdiction-clock
@@ -495,11 +504,9 @@ function main() {
495
504
  if (typeof resolver !== "function") {
496
505
  // Emit a structured JSON error matching the seven-phase verbs so operators
497
506
  // 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).
507
+ // emitError() sets exitCode + returns rather than calling process.exit()
508
+ // so the stderr JSON drains before teardown; promote the exit code to 2
509
+ // afterwards (unknown-command remains a distinct exit class).
503
510
  emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
504
511
  process.exitCode = 2;
505
512
  return;
@@ -507,7 +514,7 @@ function main() {
507
514
 
508
515
  const script = resolver();
509
516
  if (!fs.existsSync(script)) {
510
- // R-F8: same class — emitError + exitCode rather than stderr + exit().
517
+ // emitError + exitCode rather than stderr + exit() so the JSON drains.
511
518
  emitError(
512
519
  `command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
513
520
  { verb: cmd }
@@ -521,7 +528,7 @@ function main() {
521
528
  const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
522
529
  const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
523
530
  if (res.error) {
524
- // R-F8: same class emitError + exitCode.
531
+ // emitError + exitCode rather than stderr + exit() so the JSON drains.
525
532
  emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
526
533
  process.exitCode = 2;
527
534
  return;
@@ -624,7 +631,7 @@ function emitError(msg, extra, pretty) {
624
631
  }
625
632
 
626
633
  /**
627
- * EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
634
+ * Shared BOM-tolerant JSON file reader. Windows tools commonly emit
628
635
  * UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
629
636
  * `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
630
637
  * becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
@@ -699,7 +706,7 @@ function readEvidence(evidenceFlag) {
699
706
  if (stat.size > MAX_EVIDENCE_BYTES) {
700
707
  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
708
  }
702
- // EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
709
+ // Route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
703
710
  // Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
704
711
  // decode in readFileSync chokes on the leading 0xFEFF.
705
712
  return readJsonFile(evidenceFlag);
@@ -710,7 +717,7 @@ function loadRunner() {
710
717
  }
711
718
 
712
719
  /**
713
- * EE P1-7: detect whether stdin actually has data without blocking.
720
+ * Detect whether stdin actually has data without blocking.
714
721
  *
715
722
  * `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
716
723
  * `false`, `undefined`, OR `null`. Test harnesses with custom stdin
@@ -742,13 +749,12 @@ function hasReadableStdin() {
742
749
  let st;
743
750
  try { st = fs.fstatSync(0); }
744
751
  catch {
745
- // KK P1-4: fstat failed — tighten the Windows fallback to require
746
- // `isTTY === false` STRICTLY (not falsy). Pre-fix `!process.stdin.isTTY`
747
- // returned true when isTTY was undefined (Mocha/Jest test harness with
748
- // wrapped duplexer on Windows), so the caller called `fs.readFileSync(0)`
749
- // and blocked indefinitely waiting on an EOF that never came. The legacy
750
- // MSYS-bash piping scenario (R-F3 in v0.12.16) sets isTTY === false on
751
- // win32 when piped, so the strict check preserves that working case.
752
+ // fstat failed — on Windows require `isTTY === false` STRICTLY (not
753
+ // falsy). A non-strict check returns true when isTTY is undefined (e.g.
754
+ // Mocha/Jest test harnesses with a wrapped duplexer on Windows), which
755
+ // causes fs.readFileSync(0) to block indefinitely waiting on an EOF
756
+ // that never arrives. MSYS-bash piping on win32 sets isTTY === false,
757
+ // so the strict check still admits genuine piped input.
752
758
  if (process.platform === "win32") return process.stdin.isTTY === false;
753
759
  return false;
754
760
  }
@@ -778,13 +784,11 @@ function hasReadableStdin() {
778
784
  }
779
785
 
780
786
  /**
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.
787
+ * ISO-8601 shape regex applied BEFORE Date.parse for --since flags. Without
788
+ * the regex check, bare integers like "99" coerce through Date.parse to
789
+ * 1999-12-01T00:00:00Z (two-digit-year heuristic), silently filtering the
790
+ * wrong years. Requires an explicit calendar-date shape (YYYY-MM-DD with
791
+ * optional time component) before handing to Date.parse.
788
792
  *
789
793
  * Returns null on success; returns the human-facing error message string
790
794
  * on failure so the caller can wrap it with its own verb prefix.
@@ -798,7 +802,7 @@ function validateIsoSince(raw) {
798
802
  }
799
803
 
800
804
  /**
801
- * F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
805
+ * Detect whether a parsed JSON document is plausibly CycloneDX VEX or
802
806
  * OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
803
807
  * anything else, which means an operator who passes SARIF / SBOM / CSAF /
804
808
  * advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
@@ -820,12 +824,12 @@ function detectVexShape(doc) {
820
824
  const isBom = doc.bomFormat === "CycloneDX";
821
825
  const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
822
826
  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
827
+ // Empty vulnerabilities arrays cannot vouch for CycloneDX shape on their
828
+ // own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}` would
829
+ // otherwise pass because `length === 0` trivially satisfies
826
830
  // `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).
831
+ // specVersion) when the array is empty; non-empty arrays still pass when
832
+ // any entry has vex-shaped fields (id / bom-ref / analysis).
829
833
  if (doc.vulnerabilities.length === 0) {
830
834
  if (hasCyclonedxMarker) {
831
835
  return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
@@ -850,8 +854,8 @@ function detectVexShape(doc) {
850
854
  if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
851
855
  return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
852
856
  }
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
857
+ // A CycloneDX SBOM with no `vulnerabilities` key is a legitimate "0-CVE
858
+ // VEX filter" submission — the operator is asserting nothing here is
855
859
  // exploitable. Accept it as cyclonedx-vex with an empty filter set (the
856
860
  // runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
857
861
  // for documents that carry a CycloneDX-flavored specVersion ("1.x") without
@@ -928,6 +932,17 @@ function dispatchPlaybook(cmd, argv) {
928
932
  pretty
929
933
  );
930
934
  }
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
+ }
931
946
  runOpts.session_id = sid;
932
947
  }
933
948
  if (args["attestation-root"]) {
@@ -973,12 +988,12 @@ function dispatchPlaybook(cmd, argv) {
973
988
  // service identity. --operator <name> persists into the attestation file
974
989
  // for audit-trail accountability.
975
990
  //
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.
991
+ // Validate the input. Without this, a value flows into runOpts unchanged
992
+ // and an operator could inject newlines / control chars / arbitrary
993
+ // length into attestation export output (multi-line "operator:" key/value
994
+ // pairs are a forgery surface — a forged second line could look like a
995
+ // separate attestation field to a naive parser). Strip ASCII control
996
+ // chars (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
982
997
  if (args.operator !== undefined) {
983
998
  if (typeof args.operator !== "string") {
984
999
  return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
@@ -1005,8 +1020,8 @@ function dispatchPlaybook(cmd, argv) {
1005
1020
  pretty
1006
1021
  );
1007
1022
  }
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"),
1023
+ // The ASCII-only control-char regex above misses Unicode categories
1024
+ // Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
1010
1025
  // zero-width joiners (U+200B-D), invisible format chars, private-use
1011
1026
  // codepoints, unassigned codepoints. An operator string like
1012
1027
  // "alice‮evilbob" renders as "alicebobevila" in any UI that respects
@@ -1055,15 +1070,15 @@ function dispatchPlaybook(cmd, argv) {
1055
1070
  runOpts.operator = normalized;
1056
1071
  }
1057
1072
 
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.
1073
+ // --csaf-status and --publisher-namespace shape the CSAF bundle emitted by
1074
+ // phases 5-7. Verbs that don't drive those phases (brief, plan, govern,
1075
+ // direct, look, attest, list-attestations, discover, doctor, lint, ask,
1076
+ // verify-attestation, reattest) never assemble a bundle, so silently
1077
+ // consuming these flags is a UX trap. Refuse on those verbs so the
1078
+ // operator knows the flag was discarded — same pattern as --ack. Error
1079
+ // message templates and emitError prefixes use the in-scope `cmd` verb so
1080
+ // a brief invocation says "brief:" rather than misattributing the flag
1081
+ // to run.
1067
1082
  const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
1068
1083
  "run", "ci", "run-all", "ai-run", "ingest",
1069
1084
  ]);
@@ -1143,13 +1158,13 @@ function dispatchPlaybook(cmd, argv) {
1143
1158
  // consent was explicit vs. implicit. AGENTS.md says the AI should surface
1144
1159
  // and wait for ack — this is how the ack gets recorded.
1145
1160
  //
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.
1161
+ // --ack only makes sense on verbs that drive phases 5-7 (run / ingest /
1162
+ // ai-run / ci / run-all / reattest). Info-only verbs (brief, plan,
1163
+ // govern, direct, look, attest, list-attestations, discover, doctor,
1164
+ // lint, ask, verify-attestation) never consume an attestation clock —
1165
+ // accepting --ack silently is a UX trap where operators believe they have
1166
+ // recorded consent. Refuse on those verbs so the operator knows the flag
1167
+ // is irrelevant.
1153
1168
  const ACK_RELEVANT_VERBS = new Set([
1154
1169
  "run", "ingest", "ai-run", "ci", "run-all", "reattest",
1155
1170
  ]);
@@ -1418,13 +1433,22 @@ Exit codes (per-verb, post-run):
1418
1433
  (~1-2s). Distinct from 1 so callers can retry the
1419
1434
  operation rather than treat it as a hard failure.
1420
1435
  Surfaces as body.lock_contention=true,
1421
- body.exit_code=8.`,
1436
+ body.exit_code=8.
1437
+ 6-7 — reserved (6=TAMPERED on attest verifier; 7 unused)`,
1422
1438
  ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
1423
1439
 
1424
1440
  Flags:
1425
1441
  --domain <id> Playbook ID (overrides submission.playbook_id).
1426
1442
  --directive <id> Directive ID (overrides submission.directive_id).
1427
1443
  --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
1444
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1445
+ bundle. One of: draft | interim (default) | final.
1446
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1447
+ set this only after operator review of the advisory.
1448
+ --publisher-namespace <url>
1449
+ CSAF document.publisher.namespace (§3.1.7.4). The
1450
+ operator's organisation URL, NOT the tooling vendor.
1451
+ Must be an http://… or https://… URL, ≤256 chars.
1428
1452
  --pretty Indented JSON output.`,
1429
1453
  reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
1430
1454
 
@@ -1435,7 +1459,12 @@ Args / flags:
1435
1459
  --since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
1436
1460
  --pretty Indented JSON output.
1437
1461
 
1438
- Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
1462
+ Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.
1463
+
1464
+ Exit codes:
1465
+ 0 verification succeeded
1466
+ 1 generic failure
1467
+ 6 TAMPERED (sidecar or signature mismatch on the prior attestation)`,
1439
1468
  "list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
1440
1469
 
1441
1470
  Args / flags:
@@ -1465,7 +1494,12 @@ Subverbs:
1465
1494
  for an explicit pair. Reports unchanged | drifted |
1466
1495
  resolved per evidence_hash + classification deltas.
1467
1496
 
1468
- All subverbs honor --pretty for indented JSON output.`,
1497
+ All subverbs honor --pretty for indented JSON output.
1498
+
1499
+ Exit codes (attest verify):
1500
+ 0 verification succeeded
1501
+ 1 generic failure
1502
+ 6 TAMPERED (sidecar or signature mismatch)`,
1469
1503
  discover: `discover — context-aware playbook recommender (v0.11.0).
1470
1504
 
1471
1505
  Replaces: scan + dispatch + recommend.
@@ -1520,6 +1554,14 @@ Flags:
1520
1554
  --directive <id> Specific directive (default: first one).
1521
1555
  --no-stream Single-shot mode: emit all phases as one JSON doc
1522
1556
  without reading stdin (uses runner.run directly).
1557
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1558
+ bundle. One of: draft | interim (default) | final.
1559
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1560
+ set this only after operator review of the advisory.
1561
+ --publisher-namespace <url>
1562
+ CSAF document.publisher.namespace (§3.1.7.4). The
1563
+ operator's organisation URL, NOT the tooling vendor.
1564
+ Must be an http://… or https://… URL, ≤256 chars.
1523
1565
  --pretty Indented JSON output (single-shot only).
1524
1566
 
1525
1567
  Stdin event grammar (one JSON object per line):
@@ -1600,6 +1642,13 @@ Exit codes:
1600
1642
  close.notification_actions entry started a
1601
1643
  regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
1602
1644
  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.
1603
1652
 
1604
1653
  Output: verb, session_id, playbooks_run, summary{total, detected,
1605
1654
  max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
@@ -1652,7 +1701,15 @@ Flags: --pretty.`,
1652
1701
  Identical exit-code and output contract as \`run --all\`. Maintained for
1653
1702
  operators who script the verb form rather than the flag.
1654
1703
 
1655
- See \`exceptd run --help\` for the full flag list.`,
1704
+ Flags (selected — see \`exceptd run --help\` for the full list):
1705
+ --csaf-status <s> CSAF tracking.status for per-run close.evidence_package
1706
+ bundles. One of: draft | interim (default) | final.
1707
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1708
+ set this only after operator review of the advisory.
1709
+ --publisher-namespace <url>
1710
+ CSAF document.publisher.namespace (§3.1.7.4). The
1711
+ operator's organisation URL, NOT the tooling vendor.
1712
+ Must be an http://… or https://… URL, ≤256 chars.`,
1656
1713
  };
1657
1714
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
1658
1715
  }
@@ -2054,13 +2111,12 @@ function cmdRun(runner, args, runOpts, pretty) {
2054
2111
  // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
2055
2112
  // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
2056
2113
  // 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.
2114
+ // Gate on `args.scope !== undefined` rather than truthy `args.scope`.
2115
+ // `--scope ""` parses to `args.scope === ""`, which is falsy; a truthy
2116
+ // gate would silently fall through to auto-detect and run whatever
2117
+ // scopes happened to match the cwd, masking the operator's explicit
2118
+ // (if malformed) intent. An empty string reaches validateScopeOrThrow
2119
+ // which rejects with the accepted-set message.
2064
2120
  if (!positional && (args.all || args.scope !== undefined)) {
2065
2121
  let ids;
2066
2122
  if (args.all) {
@@ -2138,20 +2194,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2138
2194
  // v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
2139
2195
  // pipe, assume `--evidence -`. Operators forgetting the flag previously
2140
2196
  // 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).
2197
+ // Use the fstat-probing hasReadableStdin() helper. A raw `!isTTY` check
2198
+ // fires when isTTY is undefined (test harnesses with wrapped duplexers —
2199
+ // Mocha/Jest, Docker stdin-passthrough leave isTTY === undefined but
2200
+ // never write any bytes), which causes readFileSync(0) to block waiting
2201
+ // on an EOF that never arrives. hasReadableStdin() does an fstat() probe
2202
+ // first, then falls back to a strict isTTY===false check only on Windows
2203
+ // (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
2204
+ // isTTY === false for genuine piped input, so that path still works.
2155
2205
  if (!args.evidence && hasReadableStdin()) {
2156
2206
  args.evidence = "-";
2157
2207
  }
@@ -2187,11 +2237,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2187
2237
  // CVE ID set through to analyze() so matched_cves drops them.
2188
2238
  if (args.vex) {
2189
2239
  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
2240
+ // Cap --vex file size at 32 MiB (binary mebibytes, i.e. 32 * 1024 * 1024
2241
+ // = 33,554,432 bytes), matching readEvidence()'s --evidence cap. Without
2242
+ // the cap, a multi-GB file (binary log, JSON bomb, or accident) blocks
2243
+ // the event loop for minutes / OOM's the process. 32 MiB is well beyond
2195
2244
  // any legitimate VEX submission.
2196
2245
  const MAX_VEX_BYTES = 32 * 1024 * 1024;
2197
2246
  let vstat;
@@ -2200,7 +2249,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2200
2249
  return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
2201
2250
  }
2202
2251
  if (vstat.size > MAX_VEX_BYTES) {
2203
- // EE P1-4: error message names the binary mebi convention explicitly so
2252
+ // Error message names the binary mebi convention explicitly so
2204
2253
  // operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
2205
2254
  return emitError(
2206
2255
  `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 +2258,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2209
2258
  );
2210
2259
  }
2211
2260
  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
2261
+ // BOM-tolerant read. Windows-tool-emitted CycloneDX commonly carries
2262
+ // UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
2214
2263
  // readFileSync chokes on the leading 0xFEFF.
2215
2264
  vexDoc = readJsonFile(args.vex);
2216
2265
  } catch (e) {
2217
2266
  return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
2218
2267
  }
2219
- // F5: validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
2268
+ // Validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
2220
2269
  // The runner tolerantly returns Set(0) for anything that's not CycloneDX
2221
2270
  // or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
2222
2271
  // advisory by mistake got ZERO filter applied and ZERO feedback. Now:
@@ -2234,15 +2283,13 @@ function cmdRun(runner, args, runOpts, pretty) {
2234
2283
  const vexSet = runner.vexFilterFromDoc(vexDoc);
2235
2284
  submission.signals = submission.signals || {};
2236
2285
  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.
2286
+ // vexFilterFromDoc attaches a `.fixed` Set as an own property on the
2287
+ // returned filter Set (CycloneDX `analysis.state: 'resolved'` + OpenVEX
2288
+ // `status: 'fixed'` populate it). Forward it through to
2289
+ // signals.vex_fixed so analyze() receives the fixed-disposition CVE
2290
+ // ids, `vex_status: 'fixed'` annotates matched_cves entries, and CSAF
2291
+ // product_status.fixed + OpenVEX status:'fixed' propagate into the
2292
+ // bundle.
2246
2293
  submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
2247
2294
  } catch (e) {
2248
2295
  return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
@@ -2281,14 +2328,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2281
2328
  // ack state by that name (`result.ack` is shorter + matches the CLI flag).
2282
2329
  if (result && runOpts.operator) result.operator = runOpts.operator;
2283
2330
 
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
2331
+ // --ack consent only counts when a jurisdiction clock is actually at
2332
+ // stake — i.e. the run produced classification=detected (a real finding
2286
2333
  // 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.
2334
+ // not-detected or inconclusive run, persisting the consent would record
2335
+ // operator acknowledgement of a clock that never started. Surface the
2336
+ // ack state in the run body either way so operators see what happened,
2337
+ // but only persist `operator_consent` into the attestation when
2338
+ // classification === detected.
2292
2339
  const detectClassification = result && result.phases && result.phases.detect
2293
2340
  ? result.phases.detect.classification
2294
2341
  : null;
@@ -2313,7 +2360,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2313
2360
  directiveId: result.directive_id,
2314
2361
  evidenceHash: result.evidence_hash,
2315
2362
  operator: runOpts.operator,
2316
- // EE P1-6: gate consent persistence on classification=detected.
2363
+ // Gate consent persistence on classification=detected.
2317
2364
  operatorConsent: consentApplies ? runOpts.operator_consent : null,
2318
2365
  submission,
2319
2366
  runOpts,
@@ -2360,12 +2407,11 @@ function cmdRun(runner, args, runOpts, pretty) {
2360
2407
  }
2361
2408
 
2362
2409
  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).
2410
+ // Align preflight-halt exit code between `run --ci` and `ci`: both use
2411
+ // 4 (BLOCKED) when --ci is in effect so operators can wire one set of
2412
+ // exit-code expectations regardless of which verb they call. Without
2413
+ // --ci the legacy exit 1 is preserved (ok:false bodies are framework
2414
+ // signals when no CI gating is requested).
2369
2415
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
2370
2416
  process.exitCode = args.ci ? 4 : 1;
2371
2417
  return;
@@ -2573,9 +2619,10 @@ function cmdRun(runner, args, runOpts, pretty) {
2573
2619
  const top = rwep?.threshold?.escalate ?? "n/a";
2574
2620
  const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
2575
2621
  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.
2622
+ // F11: surface --diff-from-latest verdict in the human renderer so
2623
+ // operators see whether the run drifted from the previous attestation
2624
+ // without adding --json. One summary line follows the classification.
2625
+ // Marker text is grep-matched by tests/audit-i-l-m-fixes.test.js F11.
2579
2626
  // - unchanged: same evidence_hash as prior → reassuring single line.
2580
2627
  // - drifted: evidence differs → loud DRIFTED marker.
2581
2628
  // - no_prior_attestation_for_playbook: no line — don't clutter the
@@ -2653,7 +2700,7 @@ function cmdRun(runner, args, runOpts, pretty) {
2653
2700
  * inconclusive findings + visibility gaps) when no --evidence is given.
2654
2701
  */
2655
2702
  /**
2656
- * F13: collapse per-playbook notification_actions into a deduped rollup.
2703
+ * Collapse per-playbook notification_actions into a deduped rollup.
2657
2704
  * Multi-playbook runs frequently surface the same jurisdiction clock from
2658
2705
  * 5-10 contributing playbooks (every EU-touching playbook starts a fresh
2659
2706
  * NIS2 Art.23 24h clock). Operators were drafting one notification per
@@ -2688,12 +2735,11 @@ function buildJurisdictionClockRollup(results) {
2688
2735
  existing.deadline = n.deadline;
2689
2736
  }
2690
2737
  } 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
2738
+ // Emit `obligation` and retain `obligation_ref` as a kept-name alias
2693
2739
  // for any consumer that already parses the older shape. The dedupe
2694
2740
  // key still keys on n.obligation_ref since that's the field
2695
2741
  // notification-action stubs carry; the rollup body just exposes
2696
- // both names so the documented contract is truthful.
2742
+ // both names.
2697
2743
  const obligation = n.obligation_ref || null;
2698
2744
  m.set(key, {
2699
2745
  jurisdiction: n.jurisdiction || null,
@@ -2747,13 +2793,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2747
2793
  if (!entryPath.startsWith(resolvedDir + path.sep)) {
2748
2794
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
2749
2795
  }
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.
2796
+ // The path.resolve check above only catches `..` traversal in the
2797
+ // joined path; fs.readFileSync(entryPath) still follows symlinks, so
2798
+ // a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
2799
+ // slurp the target. lstat is symlink-aware (it does NOT follow);
2800
+ // refuse anything that's not a regular file. Defense in depth on top
2801
+ // of the readdir filter — a junction (Windows) or bind-mount can
2802
+ // shape-shift in between filter and read.
2757
2803
  let lst;
2758
2804
  try { lst = fs.lstatSync(entryPath); }
2759
2805
  catch (e) {
@@ -2765,9 +2811,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2765
2811
  if (!lst.isFile()) {
2766
2812
  return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
2767
2813
  }
2768
- // EE P1-5: Windows directory junctions are reparse-point dirs that
2814
+ // Windows directory junctions are reparse-point dirs that
2769
2815
  // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
2770
- // ordinary directories). They bypass the symlink refusal above. Use
2816
+ // ordinary directories), bypassing the symlink refusal above. Use
2771
2817
  // realpathSync to resolve the entry and confirm it still lives under
2772
2818
  // the resolved evidence-dir — the realpath approach is portable
2773
2819
  // (catches POSIX symlinks too, defense in depth) and works regardless
@@ -2784,10 +2830,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2784
2830
  pretty
2785
2831
  );
2786
2832
  }
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 —
2833
+ // Hardlink defense in depth: no clean cross-platform refusal exists —
2834
+ // hardlinks are indistinguishable from regular files at the inode
2835
+ // level. Surface a stderr warning when nlink > 1 so the operator is
2836
+ // aware a second name may point at the same file. Not a refusal —
2791
2837
  // legitimate use cases (atomic rename, package-manager dedup) produce
2792
2838
  // nlink > 1 without malicious intent.
2793
2839
  if (lst.nlink > 1) {
@@ -2815,16 +2861,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2815
2861
 
2816
2862
  const result = runner.run(id, directiveId, submission, perRunOpts);
2817
2863
 
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.
2864
+ // Per-playbook --ack gating: consent only counts when a jurisdiction
2865
+ // clock is actually at stake on THIS playbook's verdict — i.e. its
2866
+ // detect.classification === 'detected'. Without this gate, a single
2867
+ // --ack on a run-all invocation would persist explicit consent into
2868
+ // every playbook's attestation regardless of whether that playbook's
2869
+ // run started a clock. The `ack_skipped_reason` surface mirrors cmdRun
2870
+ // so consumers see exactly which playbooks consumed the ack.
2828
2871
  const perDetectClassification = result && result.phases && result.phases.detect
2829
2872
  ? result.phases.detect.classification
2830
2873
  : null;
@@ -2849,8 +2892,8 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2849
2892
  directiveId,
2850
2893
  evidenceHash: result.evidence_hash,
2851
2894
  operator: perRunOpts.operator,
2852
- // NN P1-4: gate consent persistence on this playbook's
2853
- // classification, not on the aggregate run's --ack presence.
2895
+ // Gate consent persistence on this playbook's classification, not
2896
+ // on the aggregate run's --ack presence.
2854
2897
  operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
2855
2898
  submission,
2856
2899
  runOpts: perRunOpts,
@@ -2869,12 +2912,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2869
2912
  results.push(result);
2870
2913
  }
2871
2914
 
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,
2915
+ // Dedupe jurisdiction-clock notification actions across all playbook
2916
+ // results into a single rollup. Without this, a 13-playbook multi-run
2917
+ // with 8 contributors of "EU NIS2 Art.23 24h" produces 8 separate
2918
+ // entries and operators draft 8 NIS2 notifications when one suffices.
2919
+ // Per-playbook entries are preserved on individual results; this rollup
2920
+ // is additive — keyed on (jurisdiction, regulation, obligation_ref,
2878
2921
  // window_hours) — with a triggered_by_playbooks[] list so operators see
2879
2922
  // which playbooks contributed.
2880
2923
  const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
@@ -2908,19 +2951,14 @@ function cmdIngest(runner, args, runOpts, pretty) {
2908
2951
  // `ingest` matches the AGENTS.md ingest contract. The submission JSON may
2909
2952
  // carry playbook_id + directive_id; --domain/--directive flags override.
2910
2953
  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.
2954
+ // Auto-detect piped stdin (parity with cmdRun) so
2955
+ // `echo '{...}' | exceptd ingest` reads the routing JSON instead of
2956
+ // failing with "no playbook resolved" because args.evidence stays
2957
+ // undefined.
2958
+ // Route stdin auto-detection through hasReadableStdin() (see cmdRun for
2959
+ // rationale). Wrapped-stdin test harnesses (Mocha/Jest, Docker
2960
+ // stdin-passthrough) would otherwise block here forever on the
2961
+ // readFileSync(0) call when isTTY === undefined.
2924
2962
  if (!args.evidence && hasReadableStdin()) {
2925
2963
  args.evidence = "-";
2926
2964
  }
@@ -3141,21 +3179,21 @@ function persistAttestation(args) {
3141
3179
  existingPath: path.relative(process.cwd(), filePath),
3142
3180
  };
3143
3181
  }
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.
3182
+ // Serialize the read-prior + write-new sequence behind a lockfile so
3183
+ // concurrent --force-overwrite invocations against the same session-id
3184
+ // slot do not degrade to last-write-wins. Pattern matches
3185
+ // withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling .lock file
3186
+ // with bounded retry, PID-liveness check on contention, mtime fallback
3187
+ // for orphaned lockfiles.
3188
+ // DD P1-2: MAX_RETRIES is capped at 10. persistAttestation is sync and
3189
+ // called from sync callers, so the wait loop must busy-spin (no
3190
+ // event-loop yield available). A larger bound would peg the CPU and
3191
+ // freeze the event loop for multiple seconds under attestation
3192
+ // contention. Capping at 10 bounds the freeze at ~1-2s; beyond that
3193
+ // callers receive the LOCK_CONTENTION sentinel on the result object
3194
+ // and can retry from the outside without holding the CPU. Async
3195
+ // refactor of persistAttestation + every caller is a v0.13.0
3196
+ // candidate.
3159
3197
  const lockPath = filePath + ".lock";
3160
3198
  const MAX_RETRIES = 10;
3161
3199
  const STALE_LOCK_MS = 30_000;
@@ -3194,21 +3232,18 @@ function persistAttestation(args) {
3194
3232
  }
3195
3233
  }
3196
3234
  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.
3235
+ // Surface lock_contention as a distinct sentinel so callers can
3236
+ // distinguish a genuine lock-busy condition (retry-from-outside is
3237
+ // the right move) from a hard failure (write error, permission
3238
+ // denial). The sync spin budget is bounded above so this return
3239
+ // fires after ~1-2s of contention.
3202
3240
  //
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().
3241
+ // emit() auto-maps any ok:false body to process.exitCode = 1 (only
3242
+ // when the current value is still 0). Pin process.exitCode = 8 HERE
3243
+ // before the caller hands the body to emit(); emit() preserves the
3244
+ // already-non-zero value. Exit code 8 is reserved exclusively for
3245
+ // LOCK_CONTENTION (attestation persist); see the exit-code table in
3246
+ // printGlobalHelp().
3212
3247
  process.exitCode = 8;
3213
3248
  return {
3214
3249
  ok: false,
@@ -3253,7 +3288,7 @@ function persistAttestation(args) {
3253
3288
  * from "the .sig file was deleted by an attacker."
3254
3289
  */
3255
3290
  /**
3256
- * C: byte-stability normalize() for the attestation pipeline.
3291
+ * Byte-stability normalize() for the attestation pipeline.
3257
3292
  * Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
3258
3293
  * normalize() implementations in lib/sign.js, lib/verify.js,
3259
3294
  * lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
@@ -3279,13 +3314,13 @@ function maybeSignAttestation(filePath) {
3279
3314
  // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
3280
3315
  // verifier doesn't trust.
3281
3316
  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 /
3317
+ // Normalize attestation bytes before sign — strip leading UTF-8 BOM +
3318
+ // collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
3284
3319
  // lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
3285
3320
  // attestation file lives on disk under .exceptd/ and can pick up CRLF
3286
3321
  // through git-attribute / editor round-trips on Windows; without
3287
3322
  // normalization the sign/verify pair diverges on the same logical content.
3288
- // The byte-stability contract is now five sites; tests/normalize-contract
3323
+ // The byte-stability contract spans five sites; tests/normalize-contract
3289
3324
  // .test.js enforces byte-identical output across all of them.
3290
3325
  const rawContent = fs.readFileSync(filePath, "utf8");
3291
3326
  const content = normalizeAttestationBytes(rawContent);
@@ -3309,16 +3344,15 @@ function maybeSignAttestation(filePath) {
3309
3344
  key: privateKey,
3310
3345
  dsaEncoding: "ieee-p1363",
3311
3346
  });
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.
3347
+ // The sidecar's Ed25519 signature covers ONLY the attestation file
3348
+ // bytes. Fields that travel inside the .sig but are NOT in the signed
3349
+ // message are replay-rewrite trivial: an attacker who can write the
3350
+ // directory can mutate them without invalidating the signature. The
3351
+ // sidecar therefore carries only the algorithm tag, the Ed25519
3352
+ // signature payload, and an explanatory note no `signed_at`,
3353
+ // `signs_path`, or `signs_sha256`. Operators reading freshness use
3354
+ // filesystem mtime; the attestation file's `captured_at` field is
3355
+ // what's signed.
3322
3356
  fs.writeFileSync(sigPath, JSON.stringify({
3323
3357
  algorithm: "Ed25519",
3324
3358
  signature_base64: sig.toString("base64"),
@@ -3411,6 +3445,12 @@ function walkAttestationDir(root, opts, candidates) {
3411
3445
  try {
3412
3446
  const p = path.join(sdir, f);
3413
3447
  const j = JSON.parse(fs.readFileSync(p, "utf8"));
3448
+ // Replay records (kind: 'replay') are an audit trail of force-replay
3449
+ // overrides, not a separate attestation. They have no captured_at /
3450
+ // evidence_hash and must not surface as candidates for --latest.
3451
+ // Gate on the parsed kind so a renamed file cannot smuggle a replay
3452
+ // record into the listing.
3453
+ if (j && j.kind === "replay") continue;
3414
3454
  if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
3415
3455
  if (opts.since && (j.captured_at || "") < opts.since) continue;
3416
3456
  if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
@@ -3421,14 +3461,14 @@ function walkAttestationDir(root, opts, candidates) {
3421
3461
  }
3422
3462
 
3423
3463
  /**
3424
- * F10: factored Ed25519-sidecar verification used by both `attest verify`
3425
- * and `reattest`. Returns { file, signed, verified, reason } for a given
3464
+ * Factored Ed25519-sidecar verification used by both `attest verify` and
3465
+ * `reattest`. Returns { file, signed, verified, reason } for a given
3426
3466
  * attestation file path.
3427
3467
  *
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.
3468
+ * Callers must check `signed && verified` before consuming the
3469
+ * attestation. cmdReattest refuses to replay on verify-fail unless
3470
+ * --force-replay is set, so a tampered attestation cannot silently feed
3471
+ * forged input into the drift verdict.
3432
3472
  */
3433
3473
  function verifyAttestationSidecar(attFile) {
3434
3474
  const crypto = require("crypto");
@@ -3488,15 +3528,15 @@ function verifyAttestationSidecar(attFile) {
3488
3528
  }
3489
3529
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3490
3530
  }
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.
3531
+ // Strict algorithm check. A branch on `=== "unsigned"` alone would let
3532
+ // null, undefined, "RSA-PSS", arrays, etc. fall through to crypto.verify
3533
+ // with default Ed25519 args — which can either succeed against
3534
+ // wrong-algorithm signature bytes accidentally (an attacker who can
3535
+ // write the sidecar replays an existing Ed25519 signature under a
3536
+ // downgrade-bait algorithm tag) or throw a generic verify error.
3537
+ // Refuse anything that isn't exactly "Ed25519" or "unsigned" with a
3538
+ // structured tamper class so callers can route the refusal through the
3539
+ // same exit-6 path as other tamper events.
3500
3540
  if (sigDoc.algorithm !== "Ed25519") {
3501
3541
  return {
3502
3542
  file: attFile,
@@ -3512,9 +3552,9 @@ function verifyAttestationSidecar(attFile) {
3512
3552
  let content;
3513
3553
  try {
3514
3554
  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).
3555
+ // Apply the same normalize() used by the signer so the verify path is
3556
+ // byte-stable across CRLF / BOM churn (Windows checkout with
3557
+ // core.autocrlf=true, editor round-trips, git-attributes flips).
3518
3558
  content = normalizeAttestationBytes(raw);
3519
3559
  }
3520
3560
  catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
@@ -3534,13 +3574,14 @@ function verifyAttestationSidecar(attFile) {
3534
3574
  }
3535
3575
 
3536
3576
  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.
3577
+ const crypto = require("crypto");
3578
+ // Validate --since as ISO-8601, mirroring `attest list --since`. An
3579
+ // invalid date would otherwise pass through to walkAttestationDir, where
3580
+ // the lexical comparison either matches all or none unpredictably.
3541
3581
  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.
3582
+ // ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
3583
+ // would otherwise parse as the year 1999 and silently filter wrong
3584
+ // eras.
3544
3585
  const sinceErr = validateIsoSince(args.since);
3545
3586
  if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
3546
3587
  }
@@ -3564,35 +3605,31 @@ function cmdReattest(runner, args, runOpts, pretty) {
3564
3605
  return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
3565
3606
  }
3566
3607
 
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
3608
+ // Verify the .sig sidecar BEFORE consuming the prior attestation. A
3609
+ // tampered attestation.json would otherwise be silently parsed and the
3610
+ // drift verdict computed against forged input. Refuse on verify-fail
3570
3611
  // with exit 6 (TAMPERED) unless --force-replay is explicitly set.
3571
3612
  // Unsigned attestations (no private key was available at run time) emit
3572
3613
  // a stderr warning but proceed — that's an operator config issue, not
3573
3614
  // tampering. `verified === false && signed === true` is the real tamper
3574
3615
  // signal.
3575
3616
  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.
3617
+ // Collapse tamper-class detection. Any non-benign sidecar state
3618
+ // (signed-but-invalid, sidecar-corrupt, unsigned-substitution) refuses
3619
+ // replay unless --force-replay is set. A predicate of only
3620
+ // `verify.signed && !verify.verified` would miss corrupt-JSON sidecars
3621
+ // and substituted "unsigned" sidecars on a host WITH a private key —
3622
+ // both of which let replay proceed against forged input.
3583
3623
  const isSignedTamper = verify.signed && !verify.verified;
3584
3624
  const isClassTamper = !verify.signed && (
3585
3625
  verify.tamper_class === "sidecar-corrupt"
3586
3626
  || 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.
3627
+ // Extend tamper-class refusal to algorithm-unsupported sidecars
3628
+ // anything other than "Ed25519" or "unsigned". Without explicit
3629
+ // refusal, a sidecar that throws inside crypto.verify (e.g.
3630
+ // signature_base64 missing on a downgrade-bait shape) emerges as
3631
+ // signed:true + verified:false through the catch block by accident.
3632
+ // The strict pre-check surfaces the class directly; refuse on it too.
3596
3633
  || verify.tamper_class === "algorithm-unsupported"
3597
3634
  );
3598
3635
  if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
@@ -3722,52 +3759,80 @@ function cmdReattest(runner, args, runOpts, pretty) {
3722
3759
  const sidecarVerifyClass = classifySidecarVerify(verify);
3723
3760
  const forceReplay = !!args["force-replay"];
3724
3761
 
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).
3762
+ // Persist a `replay-<isoZ>.json` record under the session directory for
3763
+ // every cmdReattest replay verdict. Without disk persistence, a
3764
+ // force-replay override emitted to stdout becomes invisible to any
3765
+ // subsequent auditor once the operator's shell closes. Each replay
3766
+ // writes a new file alongside the original attestation.json, signed via
3767
+ // the standard maybeSignAttestation path so the audit chain remains
3768
+ // tamper-evident. The file is picked up automatically by
3769
+ // `attest verify <sid>` (which iterates every *.json under the session
3770
+ // dir).
3734
3771
  //
3735
3772
  // Filename shape: ISO-8601 uses ':' which the persistAttestation regex
3736
3773
  // refuses; substitute ':' with '-' and keep millisecond precision so
3737
3774
  // multiple replays in the same second do not collide on EEXIST. The
3738
3775
  // resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
3739
- const replayFilename = "replay-" + replayedAt.replace(/:/g, "-") + ".json";
3776
+ const replayBaseName = "replay-" + replayedAt.replace(/:/g, "-");
3777
+ const replayBody = {
3778
+ kind: "replay",
3779
+ session_id: sessionId,
3780
+ playbook_id: prior.playbook_id,
3781
+ directive_id: prior.directive_id,
3782
+ status,
3783
+ prior_evidence_hash: priorHash,
3784
+ replay_evidence_hash: newHash,
3785
+ prior_captured_at: prior.captured_at,
3786
+ replayed_at: replayedAt,
3787
+ replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3788
+ replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3789
+ sidecar_verify: verify,
3790
+ sidecar_verify_class: sidecarVerifyClass,
3791
+ force_replay: forceReplay,
3792
+ };
3740
3793
  let replayPersisted = null;
3794
+ let replayPath = null;
3741
3795
  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 };
3796
+ // Retry on EEXIST: two concurrent reattests sharing the same
3797
+ // millisecond timestamp would collide on the base name. Append a short
3798
+ // random suffix until O_EXCL accepts the write or the cap is exhausted.
3799
+ const dir = path.dirname(attFile);
3800
+ const MAX_SUFFIX_TRIES = 8;
3801
+ let written = false;
3802
+ let lastErr = null;
3803
+ for (let i = 0; i < MAX_SUFFIX_TRIES; i++) {
3804
+ const suffix = i === 0 ? "" : "-" + crypto.randomBytes(3).toString("hex");
3805
+ const candidate = path.join(dir, replayBaseName + suffix + ".json");
3806
+ try {
3807
+ fs.writeFileSync(candidate, JSON.stringify(replayBody, null, 2), { flag: "wx" });
3808
+ replayPath = candidate;
3809
+ written = true;
3810
+ break;
3811
+ } catch (e) {
3812
+ lastErr = e;
3813
+ if (!e || e.code !== "EEXIST") throw e;
3814
+ }
3815
+ }
3816
+ if (!written) throw lastErr || new Error("replay-record write: EEXIST after " + MAX_SUFFIX_TRIES + " attempts");
3817
+ replayPersisted = { ok: true, path: replayPath, sidecar_signed: true };
3764
3818
  } catch (e) {
3765
- // Non-fatal — the stdout emit is the operator's primary surface; a
3819
+ // Non-fatal — stdout emit is the operator's primary surface; a
3766
3820
  // disk-persistence failure shouldn't mask the verdict. Surface the
3767
3821
  // condition in the response body so an operator-side audit pipeline
3768
3822
  // can re-run the persist later.
3769
3823
  replayPersisted = { ok: false, error: String((e && e.message) || e) };
3770
3824
  }
3825
+ if (replayPersisted && replayPersisted.ok && replayPath) {
3826
+ // Sidecar signing is best-effort: the unsigned replay record on disk
3827
+ // is still a valid audit-trail entry. Split from the write try{} so a
3828
+ // sign-time failure doesn't mask a successful write.
3829
+ try {
3830
+ maybeSignAttestation(replayPath);
3831
+ } catch (e) {
3832
+ replayPersisted.sidecar_signed = false;
3833
+ replayPersisted.sidecar_sign_error = String((e && e.message) || e);
3834
+ }
3835
+ }
3771
3836
 
3772
3837
  emit({
3773
3838
  ok: true,
@@ -3782,7 +3847,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3782
3847
  replayed_at: replayedAt,
3783
3848
  replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3784
3849
  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
3850
+ // Persist the sidecar verify result + the force-replay flag so the
3786
3851
  // audit trail records whether the replay was authenticated input.
3787
3852
  sidecar_verify: verify,
3788
3853
  // emit a one-token classification label alongside the
@@ -3800,8 +3865,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
3800
3865
  // 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
3801
3866
  sidecar_verify_class: sidecarVerifyClass,
3802
3867
  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
3868
+ // Surface the persisted replay-record path (or persistence failure
3869
+ // reason) so an auditor reading the CLI response can locate the
3805
3870
  // on-disk artifact without re-deriving the filename.
3806
3871
  replay_persisted: replayPersisted,
3807
3872
  }, pretty);
@@ -3819,7 +3884,7 @@ function classifySidecarVerify(verify) {
3819
3884
  if (verify.signed && !verify.verified) return "tampered";
3820
3885
  if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
3821
3886
  if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
3822
- // KK P1-3: algorithm-unsupported is its own class label so log scrapers /
3887
+ // `algorithm-unsupported` is its own class label so log scrapers /
3823
3888
  // dashboards can filter downgrade-bait events without parsing the reason.
3824
3889
  if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
3825
3890
  if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
@@ -3853,12 +3918,12 @@ function cmdAttest(runner, args, runOpts, pretty) {
3853
3918
  if (!sessionId) {
3854
3919
  return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
3855
3920
  }
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.
3921
+ // Distinguish "validation rejected" from "valid format but not found".
3922
+ // findSessionDir() returns null for BOTH (regex-rejected ids collapse to
3923
+ // the "no session dir" message), which gives operators a misleading
3924
+ // error — a string with `..` or `/` looks to them like an existing-
3925
+ // session lookup that failed, not a refusal. Call the same validator
3926
+ // up front; emit its specific message when it throws.
3862
3927
  try { validateSessionIdForRead(sessionId); }
3863
3928
  catch (e) {
3864
3929
  return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
@@ -3869,13 +3934,24 @@ function cmdAttest(runner, args, runOpts, pretty) {
3869
3934
  }
3870
3935
 
3871
3936
  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);
3937
+ // Partition session-dir JSON files by parsed `kind` field. Replay records
3938
+ // (written by `cmdReattest`) live alongside attestations under the same
3939
+ // session directory but represent audit-trail entries, not separate
3940
+ // sessions. Gate on the parsed payload — not filename prefix — so a
3941
+ // renamed file cannot smuggle a replay into the attestations[] list.
3942
+ const attestations = [];
3943
+ const replays = [];
3944
+ for (const f of files) {
3945
+ let parsed;
3946
+ try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
3947
+ catch { continue; }
3948
+ if (!parsed) continue;
3949
+ if (parsed.kind === "replay") replays.push(parsed);
3950
+ else attestations.push(parsed);
3951
+ }
3876
3952
 
3877
3953
  if (subverb === "show") {
3878
- emit({ session_id: sessionId, attestations }, pretty);
3954
+ emit({ session_id: sessionId, attestations, attestation_replays: replays }, pretty);
3879
3955
  return;
3880
3956
  }
3881
3957
 
@@ -3890,10 +3966,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
3890
3966
  return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
3891
3967
  }
3892
3968
  const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
3893
- if (otherFiles.length === 0) {
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.
3973
+ let other = null;
3974
+ for (const f of otherFiles) {
3975
+ 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 */ }
3981
+ }
3982
+ if (!other) {
3894
3983
  return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
3895
3984
  }
3896
- const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
3897
3985
  const self = attestations[0];
3898
3986
  emit({
3899
3987
  verb: "attest diff",
@@ -3987,10 +4075,10 @@ function cmdAttest(runner, args, runOpts, pretty) {
3987
4075
  }
3988
4076
  return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3989
4077
  }
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
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
3994
4082
  // verify-throw or a downgrade-bait acceptance path.
3995
4083
  if (sigDoc.algorithm !== "Ed25519") {
3996
4084
  return {
@@ -4002,8 +4090,8 @@ function cmdAttest(runner, args, runOpts, pretty) {
4002
4090
  };
4003
4091
  }
4004
4092
  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.
4093
+ // Normalize before crypto.verify — mirrors the signer path so the
4094
+ // verify pair is byte-stable across CRLF / BOM churn.
4007
4095
  const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
4008
4096
  const content = normalizeAttestationBytes(rawContent);
4009
4097
  try {
@@ -4015,25 +4103,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
4015
4103
  return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
4016
4104
  }
4017
4105
  });
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).
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.
4025
4110
  //
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.
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.
4031
4116
  const tampered = results.some(r =>
4032
4117
  (r.signed && !r.verified)
4033
4118
  || r.tamper_class === "sidecar-corrupt"
4034
4119
  || 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.
4120
+ // A sidecar whose algorithm field is not "Ed25519" or "unsigned" is
4121
+ // a downgrade-bait substitution; promote to exit 6.
4037
4122
  || r.tamper_class === "algorithm-unsupported"
4038
4123
  );
4039
4124
  const body = { verb: "attest verify", session_id: sessionId, results };
@@ -4836,8 +4921,9 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4836
4921
  // Prior behavior silently accepted any string and lexically compared to
4837
4922
  // captured_at, producing 0-result or full-result depending on the string.
4838
4923
  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.
4924
+ // ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
4925
+ // would otherwise parse as the year 1999 and silently filter wrong
4926
+ // eras.
4841
4927
  const sinceErr = validateIsoSince(args.since);
4842
4928
  if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
4843
4929
  }
@@ -4858,6 +4944,11 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
4858
4944
  for (const f of files) {
4859
4945
  try {
4860
4946
  const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
4947
+ // replay-<isoZ>.json records share the session dir with
4948
+ // attestation.json but are not separate sessions. Gate on the
4949
+ // parsed `kind` field rather than filename so a rename cannot
4950
+ // smuggle a replay record into the listing.
4951
+ if (j && j.kind === "replay") continue;
4861
4952
  // v0.12.14: normalized array-set filter (see top of fn).
4862
4953
  if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
4863
4954
  if (args.since && (j.captured_at || "") < args.since) continue;
@@ -4935,7 +5026,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4935
5026
  directPhase = runner.direct(playbookId, directiveId);
4936
5027
  lookPhase = runner.look(playbookId, directiveId, runOpts);
4937
5028
  } catch (e) {
4938
- // v0.12.12 (T8): process.exit(1) immediately after a stdout write can
5029
+ // process.exit(1) immediately after a stdout write can
4939
5030
  // truncate buffered output under piped consumers (same class as v0.11.10
4940
5031
  // #100). Use exitCode+return so the JSONL error frame drains. Also write
4941
5032
  // the framed error event so the stdout-only JSONL contract holds — host
@@ -4984,7 +5075,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4984
5075
  try { payload = readEvidence(args.evidence); }
4985
5076
  catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
4986
5077
  } else if (hasReadableStdin()) {
4987
- // EE P1-7: hasReadableStdin() probes via fstat before falling into
5078
+ // hasReadableStdin() probes via fstat before falling into
4988
5079
  // readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
4989
5080
  // size===0) would otherwise hang here.
4990
5081
  // Drain stdin for any evidence event.
@@ -5478,9 +5569,9 @@ function cmdCi(runner, args, runOpts, pretty) {
5478
5569
  const results = [];
5479
5570
  let fail = false;
5480
5571
  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.
5572
+ // Track jurisdiction-clock signals separately from generic FAIL so the
5573
+ // exit code can distinguish "detected/escalated" (2) from "regulatory
5574
+ // clock running, operator must notify" (5).
5484
5575
  let clockStartedFail = false;
5485
5576
  let clockStartedReasons = [];
5486
5577
 
@@ -5534,11 +5625,10 @@ function cmdCi(runner, args, runOpts, pretty) {
5534
5625
  failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
5535
5626
  }
5536
5627
  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.
5628
+ // Separate "clock started" from generic FAIL: exit 5 (CLOCK_STARTED)
5629
+ // is selected below, taking precedence over FAIL but not BLOCKED, so
5630
+ // operators can distinguish "playbook detected" from "regulatory
5631
+ // clock running."
5542
5632
  clockStartedFail = true;
5543
5633
  clockStartedReasons.push(`${id}: jurisdiction clock started`);
5544
5634
  }
@@ -5558,7 +5648,7 @@ function cmdCi(runner, args, runOpts, pretty) {
5558
5648
  const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
5559
5649
  const totalForVerdict = results.length;
5560
5650
  const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
5561
- // F18: precedence — BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
5651
+ // Precedence: BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
5562
5652
  // CLOCK_STARTED outranks FAIL because the operator explicitly opted into
5563
5653
  // the clock gate (--block-on-jurisdiction-clock); when that gate fires,
5564
5654
  // they want the regulatory-deadline signal even if a detected finding
@@ -5614,11 +5704,11 @@ function cmdCi(runner, args, runOpts, pretty) {
5614
5704
  .filter(n => n && n.clock_started_at != null).length,
5615
5705
  framework_gap_rollup: frameworkGapRollup,
5616
5706
  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.
5707
+ // Dedupe jurisdiction-clock notifications across playbooks; see
5708
+ // buildJurisdictionClockRollup. Without this, multi-playbook ci runs
5709
+ // produce one notification entry per contributing playbook (often 8+)
5710
+ // when a single notification per (jurisdiction, regulation,
5711
+ // obligation, window) is the right shape.
5622
5712
  jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
5623
5713
  verdict: computedVerdict,
5624
5714
  fail_reasons: failReasons,
@@ -5682,7 +5772,7 @@ function cmdCi(runner, args, runOpts, pretty) {
5682
5772
  process.exitCode = 4;
5683
5773
  return;
5684
5774
  }
5685
- // F18: precedence BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
5775
+ // Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
5686
5776
  // --block-on-jurisdiction-clock; when a clock fires, that's the gate
5687
5777
  // result they want to see at the exit-code layer. Per-playbook detected
5688
5778
  // findings remain in the body for them to investigate.