@blamejs/exceptd-skills 0.12.21 → 0.12.22

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,8 +85,12 @@ function assertExpectedFingerprint(pubKeyPem) {
85
85
  } catch (e) {
86
86
  return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
87
87
  }
88
- const raw = fs.readFileSync(pinPath, "utf8");
89
- const firstLine = raw.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || "";
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.
92
+ const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
93
+ const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
90
94
  if (firstLine === liveFp) return null;
91
95
  if (process.env.KEYS_ROTATED === "1") return null;
92
96
  return (
@@ -644,7 +648,21 @@ function readJsonFile(filePath) {
644
648
  text = buf.slice(2).toString("utf16le");
645
649
  } else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
646
650
  // UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
647
- const swapped = Buffer.allocUnsafe(buf.length - 2);
651
+ //
652
+ // refuse odd-length payloads up front rather than carry
653
+ // the trailing byte through a partial swap. A UTF-16BE payload by
654
+ // definition has an even byte count after the BOM; odd-length input is
655
+ // either truncated or not UTF-16BE at all.
656
+ //
657
+ // use Buffer.alloc (zero-initialised) instead of
658
+ // Buffer.allocUnsafe so an unexpected loop bound never lets uninitialised
659
+ // heap bytes leak into the decoded string and downstream JSON.parse
660
+ // error message.
661
+ const payloadLength = buf.length - 2;
662
+ if (payloadLength % 2 !== 0) {
663
+ throw new Error(`failed to read ${filePath}: UTF-16BE payload must have an even byte count after BOM; got ${payloadLength} bytes — file may be truncated.`);
664
+ }
665
+ const swapped = Buffer.alloc(payloadLength);
648
666
  for (let i = 2; i < buf.length - 1; i += 2) {
649
667
  swapped[i - 2] = buf[i + 1];
650
668
  swapped[i - 1] = buf[i];
@@ -704,15 +722,17 @@ function loadRunner() {
704
722
  * Strategy:
705
723
  *
706
724
  * 1. If isTTY is truthy → operator is at a terminal, never read stdin.
707
- * 2. Probe `fs.fstatSync(0)`:
708
- * - On POSIX pipes / regular files, `stat.size` is reliable.
709
- * - On Windows, fstat on a pipe returns size === 0 even when data
710
- * is queued so size === 0 alone cannot decide.
711
- * 3. When size > 0 real data is queued; safe to read.
712
- * 4. When size === 0 AND isTTY is falsy:
713
- * - On POSIX, treat as empty (wrapped duplexer / closed stdin).
714
- * - On Windows, fall back to the legacy truthy check so we don't
715
- * regress the MSYS-bash auto-detect (R-F3 in v0.12.16).
725
+ * 2. POSIX: trust isFIFO / isSocket / isCharacterDevice. Regular file
726
+ * requires size > 0 (empty file redirection should not be treated
727
+ * as piped input).
728
+ * 3. Windows: `isTTY === false` strict (filters out wrapped test
729
+ * duplexers which leave isTTY === undefined). DO NOT gate on size
730
+ * because Windows pipes report as regular files with size 0 even
731
+ * when bytes are queued gating would silently skip every
732
+ * `echo {...} | exceptd run` invocation.
733
+ * 4. If a wrapped test harness on Windows does want stdin auto-read
734
+ * to skip, the harness must set `process.stdin.isTTY = undefined`
735
+ * explicitly (Mocha/Jest do this by default).
716
736
  *
717
737
  * Returns `true` if the caller may safely fs.readFileSync(0) without
718
738
  * risking an indefinite block on a wrapped empty stream.
@@ -721,7 +741,17 @@ function hasReadableStdin() {
721
741
  if (process.stdin.isTTY) return false;
722
742
  let st;
723
743
  try { st = fs.fstatSync(0); }
724
- catch { return !process.stdin.isTTY; /* fstat failed — fall back */ }
744
+ 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
+ if (process.platform === "win32") return process.stdin.isTTY === false;
753
+ return false;
754
+ }
725
755
  // POSIX pipes / FIFOs / sockets / character devices report size 0
726
756
  // even when bytes are queued (or about to be). Trust them — a real
727
757
  // `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
@@ -737,8 +767,13 @@ function hasReadableStdin() {
737
767
  if (typeof st.size === "number" && st.size > 0) return true;
738
768
  // Windows fallback: pipes don't surface as FIFOs via fstat on win32
739
769
  // (they appear as regular files with size 0 even when bytes queued).
740
- // Preserve the legacy truthy check so MSYS-bash piping keeps working.
741
- if (process.platform === "win32") return !process.stdin.isTTY;
770
+ // Trust isTTY === false strictly that filters out wrapped test
771
+ // duplexers (which leave isTTY === undefined) while keeping cmd.exe /
772
+ // PowerShell / MSYS pipes working (isTTY === false when piped). Do NOT
773
+ // gate on size > 0 here: a Windows pipe with bytes queued reports as
774
+ // a regular file with size 0, and gating would silently skip every
775
+ // `echo {...} | exceptd run|ingest|ai-run` invocation.
776
+ if (process.platform === "win32" && process.stdin.isTTY === false) return true;
742
777
  return false;
743
778
  }
744
779
 
@@ -1020,7 +1055,20 @@ function dispatchPlaybook(cmd, argv) {
1020
1055
  runOpts.operator = normalized;
1021
1056
  }
1022
1057
 
1023
- // audit CC P1-3: --publisher-namespace <url> threads into the CSAF
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.
1067
+ const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
1068
+ "run", "ci", "run-all", "ai-run", "ingest",
1069
+ ]);
1070
+
1071
+ // --publisher-namespace <url> threads into the CSAF
1024
1072
  // bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
1025
1073
  // namespace to be the publisher's trust anchor — i.e. the OPERATOR
1026
1074
  // running the scan, not the tooling vendor. Pre-fix this was hard-coded
@@ -1028,28 +1076,35 @@ function dispatchPlaybook(cmd, argv) {
1028
1076
  // accuracy. Validation mirrors --operator (string, ≤256 chars, no
1029
1077
  // ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
1030
1078
  if (args["publisher-namespace"] !== undefined) {
1079
+ if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
1080
+ return emitError(
1081
+ `${cmd}: --publisher-namespace is irrelevant on this verb (no CSAF bundle is assembled). --publisher-namespace only applies to verbs that drive phases 5-7: ${[...BUNDLE_FLAG_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --publisher-namespace, or pass it on \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --publisher-namespace <url>\` once you're past the briefing step.`,
1082
+ { verb: cmd, flag: "publisher-namespace", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
1083
+ pretty
1084
+ );
1085
+ }
1031
1086
  const ns = args["publisher-namespace"];
1032
1087
  if (typeof ns !== "string") {
1033
- return emitError("run: --publisher-namespace must be a string.", { provided: typeof ns }, pretty);
1088
+ return emitError(`${cmd}: --publisher-namespace must be a string.`, { provided: typeof ns }, pretty);
1034
1089
  }
1035
1090
  // eslint-disable-next-line no-control-regex
1036
1091
  if (/[\x00-\x1F\x7F]/.test(ns)) {
1037
1092
  return emitError(
1038
- "run: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.",
1093
+ `${cmd}: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.`,
1039
1094
  { provided_length: ns.length },
1040
1095
  pretty
1041
1096
  );
1042
1097
  }
1043
1098
  if (ns.length === 0 || ns.length > 256) {
1044
1099
  return emitError(
1045
- `run: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
1100
+ `${cmd}: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
1046
1101
  { provided_length: ns.length },
1047
1102
  pretty
1048
1103
  );
1049
1104
  }
1050
1105
  if (!/^https?:\/\//i.test(ns)) {
1051
1106
  return emitError(
1052
- "run: --publisher-namespace must be a URL starting with http:// or https:// (e.g. https://your-org.example). CSAF §3.1.7.4 requires the namespace to be the publisher's trust anchor.",
1107
+ `${cmd}: --publisher-namespace must be a URL starting with http:// or https:// (e.g. https://your-org.example). CSAF §3.1.7.4 requires the namespace to be the publisher's trust anchor.`,
1053
1108
  { provided: ns.slice(0, 80) },
1054
1109
  pretty
1055
1110
  );
@@ -1057,18 +1112,25 @@ function dispatchPlaybook(cmd, argv) {
1057
1112
  runOpts.publisherNamespace = ns;
1058
1113
  }
1059
1114
 
1060
- // audit CC P1-1: --csaf-status promotes the CSAF tracking.status from the
1115
+ // --csaf-status promotes the CSAF tracking.status from the
1061
1116
  // runtime default (`interim`) to `final` for operators who have reviewed
1062
1117
  // the advisory and accept the immutable-advisory contract of CSAF
1063
1118
  // §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
1064
1119
  // rejected at input so an operator typo (`finel`) doesn't silently fall
1065
1120
  // back to interim and produce surprise.
1066
1121
  if (args["csaf-status"] !== undefined) {
1122
+ if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
1123
+ return emitError(
1124
+ `${cmd}: --csaf-status is irrelevant on this verb (no CSAF bundle is assembled). --csaf-status only applies to verbs that drive phases 5-7: ${[...BUNDLE_FLAG_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --csaf-status, or pass it on \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --csaf-status <status>\` once you're past the briefing step.`,
1125
+ { verb: cmd, flag: "csaf-status", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
1126
+ pretty
1127
+ );
1128
+ }
1067
1129
  const cs = args["csaf-status"];
1068
1130
  const allowed = ["draft", "interim", "final"];
1069
1131
  if (typeof cs !== "string" || !allowed.includes(cs)) {
1070
1132
  return emitError(
1071
- `run: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
1133
+ `${cmd}: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
1072
1134
  { provided: cs },
1073
1135
  pretty
1074
1136
  );
@@ -1305,6 +1367,15 @@ Flags:
1305
1367
  --ack Mark explicit operator consent to the jurisdiction
1306
1368
  obligations surfaced by govern. Persisted under
1307
1369
  attestation.operator_consent.
1370
+ --csaf-status <s> CSAF tracking.status for the close.evidence_package
1371
+ bundle. One of: draft | interim (default) | final.
1372
+ 'final' commits to CSAF §3.1.11.3.5.1 immutability —
1373
+ set this only after operator review of the advisory.
1374
+ --publisher-namespace <url>
1375
+ CSAF document.publisher.namespace (§3.1.7.4). The
1376
+ publisher trust anchor — i.e. the operator's
1377
+ organisation, NOT the tooling vendor. Must be an
1378
+ http://… or https://… URL, ≤256 chars.
1308
1379
  --diff-from-latest Compare evidence_hash against the most recent prior
1309
1380
  attestation for the same playbook in
1310
1381
  .exceptd/attestations/. Emits status: unchanged | drifted.
@@ -1333,7 +1404,21 @@ Flags:
1333
1404
  --pretty Indented JSON output.
1334
1405
 
1335
1406
  Attestation is persisted to .exceptd/attestations/<session_id>/ on every
1336
- successful run (single: attestation.json; multi: <playbook_id>.json).`,
1407
+ successful run (single: attestation.json; multi: <playbook_id>.json).
1408
+
1409
+ Exit codes (per-verb, post-run):
1410
+ 0 PASS Run completed; classification clean, RWEP under cap.
1411
+ 1 Framework error Runner threw, unreadable evidence, etc.
1412
+ 2 FAIL (detected) classification=detected OR rwep ≥ escalate cap.
1413
+ 3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
1414
+ 4 Blocked Result returned ok:false (preflight halt).
1415
+ 5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
1416
+ 8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
1417
+ attestation lock after the bounded retry budget
1418
+ (~1-2s). Distinct from 1 so callers can retry the
1419
+ operation rather than treat it as a hard failure.
1420
+ Surfaces as body.lock_contention=true,
1421
+ body.exit_code=8.`,
1337
1422
  ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
1338
1423
 
1339
1424
  Flags:
@@ -1493,6 +1578,11 @@ Flags:
1493
1578
  summary (5-field digest), markdown (human digest).
1494
1579
  Bundles (csaf-2.0/sarif/openvex) live on per-run
1495
1580
  attestations, not the aggregate ci verdict.
1581
+ --csaf-status <s> CSAF tracking.status threaded into per-run bundles.
1582
+ One of: draft | interim (default) | final.
1583
+ --publisher-namespace <url>
1584
+ CSAF document.publisher.namespace (§3.1.7.4). The
1585
+ operator's organisation URL, NOT the tooling vendor.
1496
1586
  --json Force single-line JSON (overrides any TTY heuristics).
1497
1587
  --pretty Indented JSON output (implies --json).
1498
1588
 
@@ -2231,10 +2321,17 @@ function cmdRun(runner, args, runOpts, pretty) {
2231
2321
  filename: "attestation.json",
2232
2322
  });
2233
2323
  if (!persistResult.ok) {
2234
- // Session-id collision without --force-overwrite. Refuse, surface the
2235
- // existing path so the operator can decide, and emit JSON to stderr
2236
- // matching the unified error shape. Exit non-zero a silent overwrite
2237
- // is a tamper-evidence violation.
2324
+ // Session-id collision without --force-overwrite, OR --force-overwrite
2325
+ // lost the lockfile race. Refuse, surface the existing path so the
2326
+ // operator can decide, emit JSON to stderr matching the unified error
2327
+ // shape. Exit non-zero — a silent overwrite is a tamper-evidence
2328
+ // violation. v0.12.14: exitCode + return instead of process.exit so
2329
+ // the stderr line drains under piped CI consumers.
2330
+ //
2331
+ // When persistAttestation lost the lockfile race it pinned
2332
+ // process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
2333
+ // overwrite that with 3 — preserve the exit-8 contract callers depend
2334
+ // on to distinguish lock-busy from collision.
2238
2335
  const err = {
2239
2336
  ok: false,
2240
2337
  error: persistResult.error,
@@ -2242,10 +2339,14 @@ function cmdRun(runner, args, runOpts, pretty) {
2242
2339
  hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
2243
2340
  verb: "run",
2244
2341
  };
2245
- // v0.12.14: exitCode + return instead of process.exit
2246
- // so the stderr line drains under piped CI consumers.
2342
+ if (persistResult.lock_contention) {
2343
+ err.lock_contention = true;
2344
+ err.exit_code = 8;
2345
+ }
2247
2346
  process.stderr.write(JSON.stringify(err) + "\n");
2248
- process.exitCode = 3;
2347
+ if (!persistResult.lock_contention) {
2348
+ process.exitCode = 3;
2349
+ }
2249
2350
  return;
2250
2351
  }
2251
2352
  if (persistResult.prior_session_id) {
@@ -2714,6 +2815,32 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2714
2815
 
2715
2816
  const result = runner.run(id, directiveId, submission, perRunOpts);
2716
2817
 
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.
2828
+ const perDetectClassification = result && result.phases && result.phases.detect
2829
+ ? result.phases.detect.classification
2830
+ : null;
2831
+ const perConsentApplies =
2832
+ !!perRunOpts.operator_consent && perDetectClassification === "detected";
2833
+ if (result && perRunOpts.operator_consent) {
2834
+ result.operator_consent = perRunOpts.operator_consent;
2835
+ result.ack = !!perRunOpts.operator_consent.explicit;
2836
+ result.ack_applied = perConsentApplies;
2837
+ if (!perConsentApplies) {
2838
+ result.ack_skipped_reason = `classification=${perDetectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2839
+ }
2840
+ } else if (result) {
2841
+ result.ack = false;
2842
+ }
2843
+
2717
2844
  // Persist per-playbook attestation under the shared session.
2718
2845
  if (result && result.ok) {
2719
2846
  const persisted = persistAttestation({
@@ -2722,7 +2849,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
2722
2849
  directiveId,
2723
2850
  evidenceHash: result.evidence_hash,
2724
2851
  operator: perRunOpts.operator,
2725
- operatorConsent: perRunOpts.operator_consent,
2852
+ // NN P1-4: gate consent persistence on this playbook's
2853
+ // classification, not on the aggregate run's --ack presence.
2854
+ operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
2726
2855
  submission,
2727
2856
  runOpts: perRunOpts,
2728
2857
  forceOverwrite: !!args["force-overwrite"],
@@ -2830,21 +2959,42 @@ function cmdIngest(runner, args, runOpts, pretty) {
2830
2959
  // calls with the same session-id silently clobbered the audit trail and no
2831
2960
  // .sig sidecar was written.
2832
2961
  if (result && result.ok && result.session_id) {
2962
+ // Mirror cmdRun / cmdRunMulti: gate operator_consent persistence on
2963
+ // classification === 'detected'. --ack is meaningful only when a
2964
+ // jurisdiction clock is at stake; persisting consent on a
2965
+ // not-detected ingest forges audit-trail consent for a clock that
2966
+ // never started.
2967
+ const ingestClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
2968
+ const ingestConsentApplies = ingestClassification === "detected";
2969
+ if (runOpts.operator_consent && !ingestConsentApplies) {
2970
+ result.ack = true;
2971
+ result.ack_applied = false;
2972
+ result.ack_skipped_reason = `classification=${ingestClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
2973
+ }
2833
2974
  const persisted = persistAttestation({
2834
2975
  sessionId: result.session_id,
2835
2976
  playbookId: result.playbook_id,
2836
2977
  directiveId: result.directive_id,
2837
2978
  evidenceHash: result.evidence_hash,
2838
2979
  operator: runOpts.operator,
2839
- operatorConsent: runOpts.operator_consent,
2980
+ operatorConsent: ingestConsentApplies ? runOpts.operator_consent : null,
2840
2981
  submission: cleanedSubmission,
2841
2982
  runOpts,
2842
2983
  forceOverwrite: !!args["force-overwrite"],
2843
2984
  filename: "attestation.json",
2844
2985
  });
2845
2986
  if (!persisted.ok) {
2846
- // Surface the collision; do not silently clobber.
2847
- return emitError(persisted.error, { session_id: result.session_id, existing_path: persisted.existingPath }, pretty);
2987
+ // Surface the collision; do not silently clobber. Preserve
2988
+ // LOCK_CONTENTION exit 8 set by persistAttestation when
2989
+ // --force-overwrite hit the lockfile race.
2990
+ const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
2991
+ if (persisted.lock_contention) {
2992
+ ctx.lock_contention = true;
2993
+ ctx.exit_code = 8;
2994
+ process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
2995
+ return;
2996
+ }
2997
+ return emitError(persisted.error, ctx, pretty);
2848
2998
  }
2849
2999
  if (persisted.prior_session_id) {
2850
3000
  result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
@@ -3049,11 +3199,23 @@ function persistAttestation(args) {
3049
3199
  // from a hard failure (write error, permission denial). The sync
3050
3200
  // spin budget was bounded above so we hit this return after ~1-2s
3051
3201
  // of contention rather than the prior ~10s.
3202
+ //
3203
+ // PP P1-2: emit() auto-maps any ok:false body to process.exitCode = 1
3204
+ // (it only writes exitCode = 1 when the current value is 0). Pre-fix
3205
+ // the LOCK_CONTENTION return collapsed onto exit 1 along with every
3206
+ // other hard failure — defeating the "callers can distinguish
3207
+ // lock-busy from hard failure" promise. Pin process.exitCode = 8
3208
+ // HERE, before the caller hands the body to emit(); emit() will
3209
+ // preserve the already-non-zero value. Exit code 8 is reserved
3210
+ // exclusively for LOCK_CONTENTION (attestation persist); see the
3211
+ // exit-code table in printGlobalHelp().
3212
+ process.exitCode = 8;
3052
3213
  return {
3053
3214
  ok: false,
3054
3215
  error: `LOCK_CONTENTION: Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts (~1-2s of contention). Retry the operation; if it persists, inspect the lockfile for a stale holder.`,
3055
3216
  existingPath: path.relative(process.cwd(), filePath),
3056
3217
  lock_contention: true,
3218
+ exit_code: 8,
3057
3219
  };
3058
3220
  }
3059
3221
  try {
@@ -3147,20 +3309,25 @@ function maybeSignAttestation(filePath) {
3147
3309
  key: privateKey,
3148
3310
  dsaEncoding: "ieee-p1363",
3149
3311
  });
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.
3150
3322
  fs.writeFileSync(sigPath, JSON.stringify({
3151
3323
  algorithm: "Ed25519",
3152
3324
  signature_base64: sig.toString("base64"),
3153
- signed_at: new Date().toISOString(),
3154
- signs_path: path.basename(filePath),
3155
- signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
3325
+ note: "Ed25519 signature covers the attestation file bytes only. Use filesystem mtime for freshness; use the attestation's `captured_at` for the signed timestamp.",
3156
3326
  }, null, 2));
3157
3327
  } else {
3158
3328
  fs.writeFileSync(sigPath, JSON.stringify({
3159
3329
  algorithm: "unsigned",
3160
3330
  signed: false,
3161
- signed_at: null,
3162
- signs_path: path.basename(filePath),
3163
- signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
3164
3331
  note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
3165
3332
  }, null, 2));
3166
3333
  }
@@ -3287,7 +3454,7 @@ function verifyAttestationSidecar(attFile) {
3287
3454
  let sigDoc;
3288
3455
  try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
3289
3456
  catch (e) {
3290
- // Audit AA P1-2: a corrupt-JSON sidecar is observationally indistinguishable
3457
+ // a corrupt-JSON sidecar is observationally indistinguishable
3291
3458
  // from sidecar tamper — an attacker who can rewrite attestation.json can
3292
3459
  // also truncate / mangle the .sig file. Surface as a distinct
3293
3460
  // tamper-class reason so callers can require --force-replay. Pre-fix,
@@ -3303,7 +3470,7 @@ function verifyAttestationSidecar(attFile) {
3303
3470
  };
3304
3471
  }
3305
3472
  if (sigDoc.algorithm === "unsigned") {
3306
- // Audit AA P1-1: `algorithm: "unsigned"` is only legitimate when written
3473
+ // `algorithm: "unsigned"` is only legitimate when written
3307
3474
  // by maybeSignAttestation() at attestation-creation time on a host
3308
3475
  // WITHOUT .keys/private.pem. If the verifying host HAS a private key,
3309
3476
  // an "unsigned" sidecar is a substitution attack: tamper attestation.json
@@ -3321,6 +3488,24 @@ function verifyAttestationSidecar(attFile) {
3321
3488
  }
3322
3489
  return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3323
3490
  }
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.
3500
+ if (sigDoc.algorithm !== "Ed25519") {
3501
+ return {
3502
+ file: attFile,
3503
+ signed: false,
3504
+ verified: false,
3505
+ reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
3506
+ tamper_class: "algorithm-unsupported",
3507
+ };
3508
+ }
3324
3509
  if (!pubKey) {
3325
3510
  return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
3326
3511
  }
@@ -3388,7 +3573,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3388
3573
  // tampering. `verified === false && signed === true` is the real tamper
3389
3574
  // signal.
3390
3575
  const verify = verifyAttestationSidecar(attFile);
3391
- // Audit AA P1-1 + P1-2: collapse tamper-class detection. Any non-benign
3576
+ // 2: collapse tamper-class detection. Any non-benign
3392
3577
  // sidecar state (signed-but-invalid, sidecar-corrupt, unsigned-substitution)
3393
3578
  // refuses replay unless --force-replay is set. The pre-fix shape only
3394
3579
  // refused on `verify.signed && !verify.verified` (signed-tamper) and on
@@ -3396,7 +3581,20 @@ function cmdReattest(runner, args, runOpts, pretty) {
3396
3581
  // "unsigned" sidecars on a host WITH a private key fell into the benign
3397
3582
  // NOTE branch and replay proceeded against forged input.
3398
3583
  const isSignedTamper = verify.signed && !verify.verified;
3399
- const isClassTamper = !verify.signed && (verify.tamper_class === "sidecar-corrupt" || verify.tamper_class === "unsigned-substitution");
3584
+ const isClassTamper = !verify.signed && (
3585
+ verify.tamper_class === "sidecar-corrupt"
3586
+ || 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.
3596
+ || verify.tamper_class === "algorithm-unsupported"
3597
+ );
3400
3598
  if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
3401
3599
  process.stderr.write(`[exceptd reattest] TAMPERED: attestation at ${attFile} failed Ed25519 verification (${verify.reason}). Refusing to replay against forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
3402
3600
  const body = {
@@ -3441,7 +3639,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
3441
3639
  } else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
3442
3640
  process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding missing .sig sidecar on ${attFile}. The replay output records sidecar_verify so the override is audit-visible.\n`);
3443
3641
  } else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && !args["force-replay"]) {
3444
- // Audit AA P1-1: legitimately-unsigned attestations (written when the
3642
+ // legitimately-unsigned attestations (written when the
3445
3643
  // attesting host had no private key) require --force-replay to consume.
3446
3644
  // Pre-fix, the NOTE branch accepted them silently — which let an
3447
3645
  // attacker swap a valid .sig with the unsigned stub on a host that
@@ -3520,6 +3718,57 @@ function cmdReattest(runner, args, runOpts, pretty) {
3520
3718
  }
3521
3719
  }
3522
3720
 
3721
+ const replayedAt = new Date().toISOString();
3722
+ const sidecarVerifyClass = classifySidecarVerify(verify);
3723
+ const forceReplay = !!args["force-replay"];
3724
+
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).
3734
+ //
3735
+ // Filename shape: ISO-8601 uses ':' which the persistAttestation regex
3736
+ // refuses; substitute ':' with '-' and keep millisecond precision so
3737
+ // multiple replays in the same second do not collide on EEXIST. The
3738
+ // resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
3739
+ const replayFilename = "replay-" + replayedAt.replace(/:/g, "-") + ".json";
3740
+ let replayPersisted = null;
3741
+ 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 };
3764
+ } catch (e) {
3765
+ // Non-fatal — the stdout emit is the operator's primary surface; a
3766
+ // disk-persistence failure shouldn't mask the verdict. Surface the
3767
+ // condition in the response body so an operator-side audit pipeline
3768
+ // can re-run the persist later.
3769
+ replayPersisted = { ok: false, error: String((e && e.message) || e) };
3770
+ }
3771
+
3523
3772
  emit({
3524
3773
  ok: true,
3525
3774
  verb: "reattest",
@@ -3530,13 +3779,13 @@ function cmdReattest(runner, args, runOpts, pretty) {
3530
3779
  prior_evidence_hash: priorHash,
3531
3780
  replay_evidence_hash: newHash,
3532
3781
  prior_captured_at: prior.captured_at,
3533
- replayed_at: new Date().toISOString(),
3782
+ replayed_at: replayedAt,
3534
3783
  replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
3535
3784
  replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
3536
3785
  // F10: persist the sidecar verify result + the force-replay flag so the
3537
3786
  // audit trail records whether the replay was authenticated input.
3538
3787
  sidecar_verify: verify,
3539
- // Audit AA P1-1: emit a one-token classification label alongside the
3788
+ // emit a one-token classification label alongside the
3540
3789
  // full sidecar_verify object so log scrapers / dashboards can filter on
3541
3790
  // override events without parsing reason strings. Values:
3542
3791
  // 'verified' — Ed25519 sidecar verified
@@ -3544,16 +3793,22 @@ function cmdReattest(runner, args, runOpts, pretty) {
3544
3793
  // 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
3545
3794
  // 'unsigned-substitution'— "unsigned" sidecar on a host with private key
3546
3795
  // (substitution attack signal)
3796
+ // 'algorithm-unsupported'— sidecar algorithm field is neither "Ed25519"
3797
+ // nor "unsigned" (downgrade-bait substitution)
3547
3798
  // 'explicitly-unsigned' — legitimately-unsigned attestation
3548
3799
  // 'no-sidecar' — sidecar file absent
3549
3800
  // 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
3550
- sidecar_verify_class: classifySidecarVerify(verify),
3551
- force_replay: !!args["force-replay"],
3801
+ sidecar_verify_class: sidecarVerifyClass,
3802
+ 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
3805
+ // on-disk artifact without re-deriving the filename.
3806
+ replay_persisted: replayPersisted,
3552
3807
  }, pretty);
3553
3808
  }
3554
3809
 
3555
3810
  /**
3556
- * Audit AA P1-1: map a verifyAttestationSidecar() result to a one-token
3811
+ * map a verifyAttestationSidecar() result to a one-token
3557
3812
  * classification label. The label is persisted alongside the full
3558
3813
  * sidecar_verify object so auditors can filter override events by class
3559
3814
  * without regexing the human-readable reason string.
@@ -3564,6 +3819,9 @@ function classifySidecarVerify(verify) {
3564
3819
  if (verify.signed && !verify.verified) return "tampered";
3565
3820
  if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
3566
3821
  if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
3822
+ // KK P1-3: algorithm-unsupported is its own class label so log scrapers /
3823
+ // dashboards can filter downgrade-bait events without parsing the reason.
3824
+ if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
3567
3825
  if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
3568
3826
  if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
3569
3827
  if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
@@ -3689,7 +3947,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3689
3947
  pretty
3690
3948
  );
3691
3949
  }
3692
- // Audit AA P1-1: on the verifying host, detect "unsigned" sidecar
3950
+ // on the verifying host, detect "unsigned" sidecar
3693
3951
  // substitution by checking whether .keys/private.pem is present. A
3694
3952
  // legitimately-unsigned attestation cannot coexist with a private key on
3695
3953
  // the same host — that combination is sidecar substitution (attacker
@@ -3699,7 +3957,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3699
3957
  const results = files.map(f => {
3700
3958
  const sigPath = path.join(dir, f + ".sig");
3701
3959
  if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
3702
- // Audit AA P1-2: wrap JSON.parse so a corrupt sidecar surfaces as a
3960
+ // wrap JSON.parse so a corrupt sidecar surfaces as a
3703
3961
  // structured tamper-class result (signed:false, verified:false,
3704
3962
  // tamper_class:"sidecar-corrupt") rather than throwing into the outer
3705
3963
  // dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
@@ -3717,7 +3975,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3717
3975
  };
3718
3976
  }
3719
3977
  if (sigDoc.algorithm === "unsigned") {
3720
- // Audit AA P1-1: substitution detection.
3978
+ // substitution detection.
3721
3979
  if (hasPrivKey) {
3722
3980
  return {
3723
3981
  file: f,
@@ -3729,6 +3987,20 @@ function cmdAttest(runner, args, runOpts, pretty) {
3729
3987
  }
3730
3988
  return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
3731
3989
  }
3990
+ // KK P1-3: strict algorithm check (mirrors verifyAttestationSidecar).
3991
+ // Anything that isn't exactly "Ed25519" or "unsigned" is refused as
3992
+ // tamper-class. Pre-fix null / "RSA-PSS" / arrays fell through to
3993
+ // crypto.verify with Ed25519 defaults, producing either an opaque
3994
+ // verify-throw or a downgrade-bait acceptance path.
3995
+ if (sigDoc.algorithm !== "Ed25519") {
3996
+ return {
3997
+ file: f,
3998
+ signed: false,
3999
+ verified: false,
4000
+ reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
4001
+ tamper_class: "algorithm-unsupported",
4002
+ };
4003
+ }
3732
4004
  if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
3733
4005
  // C: normalize before crypto.verify — mirrors the signer
3734
4006
  // path so the verify pair is byte-stable across CRLF / BOM churn.
@@ -3751,7 +4023,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
3751
4023
  // → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
3752
4024
  // from generic failure, so explicitly raise to 6 (cmdReattest's code).
3753
4025
  //
3754
- // Audit AA P1-1 + P1-2: extend the tamper predicate to cover the new
4026
+ // 2: extend the tamper predicate to cover the new
3755
4027
  // tamper_class variants. Pre-fix the predicate was `r.signed && !r.verified`
3756
4028
  // which missed (a) corrupt-JSON sidecars (signed:false) and (b) "unsigned"
3757
4029
  // sidecar substitution on hosts with a private key (signed:false). Both
@@ -3760,6 +4032,9 @@ function cmdAttest(runner, args, runOpts, pretty) {
3760
4032
  (r.signed && !r.verified)
3761
4033
  || r.tamper_class === "sidecar-corrupt"
3762
4034
  || r.tamper_class === "unsigned-substitution"
4035
+ // KK P1-3: a sidecar whose algorithm field is not "Ed25519" or
4036
+ // "unsigned" is a downgrade-bait substitution; promote to exit 6.
4037
+ || r.tamper_class === "algorithm-unsupported"
3763
4038
  );
3764
4039
  const body = { verb: "attest verify", session_id: sessionId, results };
3765
4040
  if (tampered) {
@@ -4758,13 +5033,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4758
5033
  // or `reattest` with the returned id. Now: same persistAttestation
4759
5034
  // shape as cmdRun, so AI-facing flow round-trips cleanly.
4760
5035
  if (result.session_id) {
5036
+ // Mirror cmdRun: gate operator_consent on classification === 'detected'.
5037
+ const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
5038
+ const aiConsentApplies = aiClassification === "detected";
5039
+ if (runOpts.operator_consent && !aiConsentApplies) {
5040
+ result.ack = true;
5041
+ result.ack_applied = false;
5042
+ result.ack_skipped_reason = `classification=${aiClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
5043
+ }
4761
5044
  const persistResult = persistAttestation({
4762
5045
  sessionId: result.session_id,
4763
5046
  playbookId: result.playbook_id || playbookId,
4764
5047
  directiveId: result.directive_id || directiveId,
4765
5048
  evidenceHash: result.evidence_hash,
4766
5049
  operator: runOpts.operator,
4767
- operatorConsent: runOpts.operator_consent,
5050
+ operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
4768
5051
  submission,
4769
5052
  runOpts,
4770
5053
  forceOverwrite: !!args["force-overwrite"],
@@ -4773,12 +5056,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4773
5056
  if (!persistResult.ok && !args["force-overwrite"]) {
4774
5057
  // Collision without --force-overwrite. AI agents typically pass
4775
5058
  // unique session ids each run, so this path is rare but surface
4776
- // it cleanly via the same JSONL contract.
4777
- process.stdout.write(JSON.stringify({
5059
+ // it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
5060
+ // exit 8 set by persistAttestation when --force-overwrite hit the
5061
+ // lockfile race — don't clobber with exit 3.
5062
+ const eventBody = {
4778
5063
  event: "error", reason: persistResult.error,
4779
5064
  existing_attestation: persistResult.existingPath,
4780
- }) + "\n");
4781
- process.exitCode = 3;
5065
+ };
5066
+ if (persistResult.lock_contention) {
5067
+ eventBody.lock_contention = true;
5068
+ eventBody.exit_code = 8;
5069
+ }
5070
+ process.stdout.write(JSON.stringify(eventBody) + "\n");
5071
+ if (!persistResult.lock_contention) {
5072
+ process.exitCode = 3;
5073
+ }
4782
5074
  return;
4783
5075
  }
4784
5076
  }
@@ -4862,21 +5154,31 @@ function cmdAiRun(runner, args, runOpts, pretty) {
4862
5154
  // too. Without this, the session_id emitted in the `done` frame
4863
5155
  // can't be resolved by `attest show / verify / diff` or `reattest`.
4864
5156
  if (result.session_id) {
5157
+ // Mirror cmdRun: gate operator_consent on classification === 'detected'.
5158
+ const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
5159
+ const aiConsentApplies = aiClassification === "detected";
4865
5160
  const persistResult = persistAttestation({
4866
5161
  sessionId: result.session_id,
4867
5162
  playbookId: result.playbook_id || playbookId,
4868
5163
  directiveId: result.directive_id || directiveId,
4869
5164
  evidenceHash: result.evidence_hash,
4870
5165
  operator: runOpts.operator,
4871
- operatorConsent: runOpts.operator_consent,
5166
+ operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
4872
5167
  submission,
4873
5168
  runOpts,
4874
5169
  forceOverwrite: !!args["force-overwrite"],
4875
5170
  filename: "attestation.json",
4876
5171
  });
4877
5172
  if (!persistResult.ok && !args["force-overwrite"]) {
4878
- writeLine({ event: "error", reason: persistResult.error,
4879
- existing_attestation: persistResult.existingPath });
5173
+ const eventBody = { event: "error", reason: persistResult.error,
5174
+ existing_attestation: persistResult.existingPath };
5175
+ if (persistResult.lock_contention) {
5176
+ eventBody.lock_contention = true;
5177
+ eventBody.exit_code = 8;
5178
+ writeLine(eventBody);
5179
+ return finish(8);
5180
+ }
5181
+ writeLine(eventBody);
4880
5182
  return finish(3);
4881
5183
  }
4882
5184
  }