@blamejs/exceptd-skills 0.12.21 → 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/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +103 -24
- package/CONTEXT.md +126 -69
- package/README.md +7 -7
- package/bin/exceptd.js +687 -295
- package/data/_indexes/_meta.json +4 -4
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +3 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +15 -1
- package/lib/auto-discovery.js +2 -2
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/lint-skills.js +5 -5
- package/lib/playbook-runner.js +406 -274
- package/lib/prefetch.js +21 -21
- package/lib/refresh-external.js +15 -18
- package/lib/refresh-network.js +39 -13
- package/lib/scoring.js +8 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +3 -3
- package/lib/verify.js +63 -22
- package/manifest.json +41 -41
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +22 -18
- package/skills/threat-model-currency/skill.md +1 -1
package/bin/exceptd.js
CHANGED
|
@@ -85,10 +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
|
-
|
|
89
|
-
|
|
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
|
+
const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
|
|
93
|
+
const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
|
|
90
94
|
if (firstLine === liveFp) return null;
|
|
91
|
-
if (process.env.KEYS_ROTATED === "1")
|
|
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
|
+
}
|
|
92
103
|
return (
|
|
93
104
|
`EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
|
|
94
105
|
`If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
|
|
@@ -264,9 +275,11 @@ v0.12.0 canonical surface
|
|
|
264
275
|
--registry-check (v0.11.14) opt-in: query npm registry
|
|
265
276
|
for latest published version + days behind
|
|
266
277
|
|
|
267
|
-
ci One-shot CI gate. Exit codes: 0 PASS,
|
|
268
|
-
3 ran-but-no-evidence,
|
|
269
|
-
5 jurisdiction clock started,
|
|
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).
|
|
270
283
|
--all | --scope <type> | (auto-detect)
|
|
271
284
|
--max-rwep <n> cap below playbook default
|
|
272
285
|
--block-on-jurisdiction-clock
|
|
@@ -491,11 +504,9 @@ function main() {
|
|
|
491
504
|
if (typeof resolver !== "function") {
|
|
492
505
|
// Emit a structured JSON error matching the seven-phase verbs so operators
|
|
493
506
|
// piping through `jq` get one consistent shape across the CLI surface.
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
// exitCode + return per v0.12.14) so the JSON drains, then promote
|
|
498
|
-
// 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).
|
|
499
510
|
emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
|
|
500
511
|
process.exitCode = 2;
|
|
501
512
|
return;
|
|
@@ -503,7 +514,7 @@ function main() {
|
|
|
503
514
|
|
|
504
515
|
const script = resolver();
|
|
505
516
|
if (!fs.existsSync(script)) {
|
|
506
|
-
//
|
|
517
|
+
// emitError + exitCode rather than stderr + exit() so the JSON drains.
|
|
507
518
|
emitError(
|
|
508
519
|
`command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
|
|
509
520
|
{ verb: cmd }
|
|
@@ -517,7 +528,7 @@ function main() {
|
|
|
517
528
|
const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
|
|
518
529
|
const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
|
|
519
530
|
if (res.error) {
|
|
520
|
-
//
|
|
531
|
+
// emitError + exitCode rather than stderr + exit() so the JSON drains.
|
|
521
532
|
emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
|
|
522
533
|
process.exitCode = 2;
|
|
523
534
|
return;
|
|
@@ -620,7 +631,7 @@ function emitError(msg, extra, pretty) {
|
|
|
620
631
|
}
|
|
621
632
|
|
|
622
633
|
/**
|
|
623
|
-
*
|
|
634
|
+
* Shared BOM-tolerant JSON file reader. Windows tools commonly emit
|
|
624
635
|
* UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
|
|
625
636
|
* `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
|
|
626
637
|
* becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
|
|
@@ -644,7 +655,21 @@ function readJsonFile(filePath) {
|
|
|
644
655
|
text = buf.slice(2).toString("utf16le");
|
|
645
656
|
} else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
|
|
646
657
|
// UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
|
|
647
|
-
|
|
658
|
+
//
|
|
659
|
+
// refuse odd-length payloads up front rather than carry
|
|
660
|
+
// the trailing byte through a partial swap. A UTF-16BE payload by
|
|
661
|
+
// definition has an even byte count after the BOM; odd-length input is
|
|
662
|
+
// either truncated or not UTF-16BE at all.
|
|
663
|
+
//
|
|
664
|
+
// use Buffer.alloc (zero-initialised) instead of
|
|
665
|
+
// Buffer.allocUnsafe so an unexpected loop bound never lets uninitialised
|
|
666
|
+
// heap bytes leak into the decoded string and downstream JSON.parse
|
|
667
|
+
// error message.
|
|
668
|
+
const payloadLength = buf.length - 2;
|
|
669
|
+
if (payloadLength % 2 !== 0) {
|
|
670
|
+
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.`);
|
|
671
|
+
}
|
|
672
|
+
const swapped = Buffer.alloc(payloadLength);
|
|
648
673
|
for (let i = 2; i < buf.length - 1; i += 2) {
|
|
649
674
|
swapped[i - 2] = buf[i + 1];
|
|
650
675
|
swapped[i - 1] = buf[i];
|
|
@@ -681,7 +706,7 @@ function readEvidence(evidenceFlag) {
|
|
|
681
706
|
if (stat.size > MAX_EVIDENCE_BYTES) {
|
|
682
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.`);
|
|
683
708
|
}
|
|
684
|
-
//
|
|
709
|
+
// Route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
|
|
685
710
|
// Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
|
|
686
711
|
// decode in readFileSync chokes on the leading 0xFEFF.
|
|
687
712
|
return readJsonFile(evidenceFlag);
|
|
@@ -692,7 +717,7 @@ function loadRunner() {
|
|
|
692
717
|
}
|
|
693
718
|
|
|
694
719
|
/**
|
|
695
|
-
*
|
|
720
|
+
* Detect whether stdin actually has data without blocking.
|
|
696
721
|
*
|
|
697
722
|
* `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
|
|
698
723
|
* `false`, `undefined`, OR `null`. Test harnesses with custom stdin
|
|
@@ -704,15 +729,17 @@ function loadRunner() {
|
|
|
704
729
|
* Strategy:
|
|
705
730
|
*
|
|
706
731
|
* 1. If isTTY is truthy → operator is at a terminal, never read stdin.
|
|
707
|
-
* 2.
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
732
|
+
* 2. POSIX: trust isFIFO / isSocket / isCharacterDevice. Regular file
|
|
733
|
+
* requires size > 0 (empty file redirection should not be treated
|
|
734
|
+
* as piped input).
|
|
735
|
+
* 3. Windows: `isTTY === false` strict (filters out wrapped test
|
|
736
|
+
* duplexers which leave isTTY === undefined). DO NOT gate on size
|
|
737
|
+
* because Windows pipes report as regular files with size 0 even
|
|
738
|
+
* when bytes are queued — gating would silently skip every
|
|
739
|
+
* `echo {...} | exceptd run` invocation.
|
|
740
|
+
* 4. If a wrapped test harness on Windows does want stdin auto-read
|
|
741
|
+
* to skip, the harness must set `process.stdin.isTTY = undefined`
|
|
742
|
+
* explicitly (Mocha/Jest do this by default).
|
|
716
743
|
*
|
|
717
744
|
* Returns `true` if the caller may safely fs.readFileSync(0) without
|
|
718
745
|
* risking an indefinite block on a wrapped empty stream.
|
|
@@ -721,7 +748,16 @@ function hasReadableStdin() {
|
|
|
721
748
|
if (process.stdin.isTTY) return false;
|
|
722
749
|
let st;
|
|
723
750
|
try { st = fs.fstatSync(0); }
|
|
724
|
-
catch {
|
|
751
|
+
catch {
|
|
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.
|
|
758
|
+
if (process.platform === "win32") return process.stdin.isTTY === false;
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
725
761
|
// POSIX pipes / FIFOs / sockets / character devices report size 0
|
|
726
762
|
// even when bytes are queued (or about to be). Trust them — a real
|
|
727
763
|
// `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
|
|
@@ -737,19 +773,22 @@ function hasReadableStdin() {
|
|
|
737
773
|
if (typeof st.size === "number" && st.size > 0) return true;
|
|
738
774
|
// Windows fallback: pipes don't surface as FIFOs via fstat on win32
|
|
739
775
|
// (they appear as regular files with size 0 even when bytes queued).
|
|
740
|
-
//
|
|
741
|
-
|
|
776
|
+
// Trust isTTY === false strictly — that filters out wrapped test
|
|
777
|
+
// duplexers (which leave isTTY === undefined) while keeping cmd.exe /
|
|
778
|
+
// PowerShell / MSYS pipes working (isTTY === false when piped). Do NOT
|
|
779
|
+
// gate on size > 0 here: a Windows pipe with bytes queued reports as
|
|
780
|
+
// a regular file with size 0, and gating would silently skip every
|
|
781
|
+
// `echo {...} | exceptd run|ingest|ai-run` invocation.
|
|
782
|
+
if (process.platform === "win32" && process.stdin.isTTY === false) return true;
|
|
742
783
|
return false;
|
|
743
784
|
}
|
|
744
785
|
|
|
745
786
|
/**
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* filtered the wrong years. Now: require an explicit calendar-date shape
|
|
752
|
-
* (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.
|
|
753
792
|
*
|
|
754
793
|
* Returns null on success; returns the human-facing error message string
|
|
755
794
|
* on failure so the caller can wrap it with its own verb prefix.
|
|
@@ -763,7 +802,7 @@ function validateIsoSince(raw) {
|
|
|
763
802
|
}
|
|
764
803
|
|
|
765
804
|
/**
|
|
766
|
-
*
|
|
805
|
+
* Detect whether a parsed JSON document is plausibly CycloneDX VEX or
|
|
767
806
|
* OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
|
|
768
807
|
* anything else, which means an operator who passes SARIF / SBOM / CSAF /
|
|
769
808
|
* advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
|
|
@@ -785,12 +824,12 @@ function detectVexShape(doc) {
|
|
|
785
824
|
const isBom = doc.bomFormat === "CycloneDX";
|
|
786
825
|
const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
|
|
787
826
|
const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
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
|
|
791
830
|
// `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
|
|
792
|
-
// specVersion) when the array is empty; non-empty arrays still pass
|
|
793
|
-
//
|
|
831
|
+
// specVersion) when the array is empty; non-empty arrays still pass when
|
|
832
|
+
// any entry has vex-shaped fields (id / bom-ref / analysis).
|
|
794
833
|
if (doc.vulnerabilities.length === 0) {
|
|
795
834
|
if (hasCyclonedxMarker) {
|
|
796
835
|
return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
|
|
@@ -815,8 +854,8 @@ function detectVexShape(doc) {
|
|
|
815
854
|
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
816
855
|
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
817
856
|
}
|
|
818
|
-
//
|
|
819
|
-
//
|
|
857
|
+
// A CycloneDX SBOM with no `vulnerabilities` key is a legitimate "0-CVE
|
|
858
|
+
// VEX filter" submission — the operator is asserting nothing here is
|
|
820
859
|
// exploitable. Accept it as cyclonedx-vex with an empty filter set (the
|
|
821
860
|
// runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
|
|
822
861
|
// for documents that carry a CycloneDX-flavored specVersion ("1.x") without
|
|
@@ -893,6 +932,17 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
893
932
|
pretty
|
|
894
933
|
);
|
|
895
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
|
+
}
|
|
896
946
|
runOpts.session_id = sid;
|
|
897
947
|
}
|
|
898
948
|
if (args["attestation-root"]) {
|
|
@@ -938,12 +988,12 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
938
988
|
// service identity. --operator <name> persists into the attestation file
|
|
939
989
|
// for audit-trail accountability.
|
|
940
990
|
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
// into attestation export output (multi-line "operator:" key/value
|
|
944
|
-
// are a forgery surface — a forged second line could look like a
|
|
945
|
-
// attestation field to a naive parser).
|
|
946
|
-
// (\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.
|
|
947
997
|
if (args.operator !== undefined) {
|
|
948
998
|
if (typeof args.operator !== "string") {
|
|
949
999
|
return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
|
|
@@ -970,8 +1020,8 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
970
1020
|
pretty
|
|
971
1021
|
);
|
|
972
1022
|
}
|
|
973
|
-
//
|
|
974
|
-
//
|
|
1023
|
+
// The ASCII-only control-char regex above misses Unicode categories
|
|
1024
|
+
// Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
|
|
975
1025
|
// zero-width joiners (U+200B-D), invisible format chars, private-use
|
|
976
1026
|
// codepoints, unassigned codepoints. An operator string like
|
|
977
1027
|
// "aliceevilbob" renders as "alicebobevila" in any UI that respects
|
|
@@ -1020,7 +1070,20 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1020
1070
|
runOpts.operator = normalized;
|
|
1021
1071
|
}
|
|
1022
1072
|
|
|
1023
|
-
//
|
|
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.
|
|
1082
|
+
const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
|
|
1083
|
+
"run", "ci", "run-all", "ai-run", "ingest",
|
|
1084
|
+
]);
|
|
1085
|
+
|
|
1086
|
+
// --publisher-namespace <url> threads into the CSAF
|
|
1024
1087
|
// bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
|
|
1025
1088
|
// namespace to be the publisher's trust anchor — i.e. the OPERATOR
|
|
1026
1089
|
// running the scan, not the tooling vendor. Pre-fix this was hard-coded
|
|
@@ -1028,28 +1091,35 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1028
1091
|
// accuracy. Validation mirrors --operator (string, ≤256 chars, no
|
|
1029
1092
|
// ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
|
|
1030
1093
|
if (args["publisher-namespace"] !== undefined) {
|
|
1094
|
+
if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
|
|
1095
|
+
return emitError(
|
|
1096
|
+
`${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.`,
|
|
1097
|
+
{ verb: cmd, flag: "publisher-namespace", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
|
|
1098
|
+
pretty
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1031
1101
|
const ns = args["publisher-namespace"];
|
|
1032
1102
|
if (typeof ns !== "string") {
|
|
1033
|
-
return emitError(
|
|
1103
|
+
return emitError(`${cmd}: --publisher-namespace must be a string.`, { provided: typeof ns }, pretty);
|
|
1034
1104
|
}
|
|
1035
1105
|
// eslint-disable-next-line no-control-regex
|
|
1036
1106
|
if (/[\x00-\x1F\x7F]/.test(ns)) {
|
|
1037
1107
|
return emitError(
|
|
1038
|
-
|
|
1108
|
+
`${cmd}: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.`,
|
|
1039
1109
|
{ provided_length: ns.length },
|
|
1040
1110
|
pretty
|
|
1041
1111
|
);
|
|
1042
1112
|
}
|
|
1043
1113
|
if (ns.length === 0 || ns.length > 256) {
|
|
1044
1114
|
return emitError(
|
|
1045
|
-
|
|
1115
|
+
`${cmd}: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
|
|
1046
1116
|
{ provided_length: ns.length },
|
|
1047
1117
|
pretty
|
|
1048
1118
|
);
|
|
1049
1119
|
}
|
|
1050
1120
|
if (!/^https?:\/\//i.test(ns)) {
|
|
1051
1121
|
return emitError(
|
|
1052
|
-
|
|
1122
|
+
`${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
1123
|
{ provided: ns.slice(0, 80) },
|
|
1054
1124
|
pretty
|
|
1055
1125
|
);
|
|
@@ -1057,18 +1127,25 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1057
1127
|
runOpts.publisherNamespace = ns;
|
|
1058
1128
|
}
|
|
1059
1129
|
|
|
1060
|
-
//
|
|
1130
|
+
// --csaf-status promotes the CSAF tracking.status from the
|
|
1061
1131
|
// runtime default (`interim`) to `final` for operators who have reviewed
|
|
1062
1132
|
// the advisory and accept the immutable-advisory contract of CSAF
|
|
1063
1133
|
// §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
|
|
1064
1134
|
// rejected at input so an operator typo (`finel`) doesn't silently fall
|
|
1065
1135
|
// back to interim and produce surprise.
|
|
1066
1136
|
if (args["csaf-status"] !== undefined) {
|
|
1137
|
+
if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
|
|
1138
|
+
return emitError(
|
|
1139
|
+
`${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.`,
|
|
1140
|
+
{ verb: cmd, flag: "csaf-status", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
|
|
1141
|
+
pretty
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1067
1144
|
const cs = args["csaf-status"];
|
|
1068
1145
|
const allowed = ["draft", "interim", "final"];
|
|
1069
1146
|
if (typeof cs !== "string" || !allowed.includes(cs)) {
|
|
1070
1147
|
return emitError(
|
|
1071
|
-
|
|
1148
|
+
`${cmd}: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
|
|
1072
1149
|
{ provided: cs },
|
|
1073
1150
|
pretty
|
|
1074
1151
|
);
|
|
@@ -1081,13 +1158,13 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1081
1158
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
1082
1159
|
// and wait for ack — this is how the ack gets recorded.
|
|
1083
1160
|
//
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
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.
|
|
1091
1168
|
const ACK_RELEVANT_VERBS = new Set([
|
|
1092
1169
|
"run", "ingest", "ai-run", "ci", "run-all", "reattest",
|
|
1093
1170
|
]);
|
|
@@ -1305,6 +1382,15 @@ Flags:
|
|
|
1305
1382
|
--ack Mark explicit operator consent to the jurisdiction
|
|
1306
1383
|
obligations surfaced by govern. Persisted under
|
|
1307
1384
|
attestation.operator_consent.
|
|
1385
|
+
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1386
|
+
bundle. One of: draft | interim (default) | final.
|
|
1387
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1388
|
+
set this only after operator review of the advisory.
|
|
1389
|
+
--publisher-namespace <url>
|
|
1390
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1391
|
+
publisher trust anchor — i.e. the operator's
|
|
1392
|
+
organisation, NOT the tooling vendor. Must be an
|
|
1393
|
+
http://… or https://… URL, ≤256 chars.
|
|
1308
1394
|
--diff-from-latest Compare evidence_hash against the most recent prior
|
|
1309
1395
|
attestation for the same playbook in
|
|
1310
1396
|
.exceptd/attestations/. Emits status: unchanged | drifted.
|
|
@@ -1333,13 +1419,36 @@ Flags:
|
|
|
1333
1419
|
--pretty Indented JSON output.
|
|
1334
1420
|
|
|
1335
1421
|
Attestation is persisted to .exceptd/attestations/<session_id>/ on every
|
|
1336
|
-
successful run (single: attestation.json; multi: <playbook_id>.json)
|
|
1422
|
+
successful run (single: attestation.json; multi: <playbook_id>.json).
|
|
1423
|
+
|
|
1424
|
+
Exit codes (per-verb, post-run):
|
|
1425
|
+
0 PASS Run completed; classification clean, RWEP under cap.
|
|
1426
|
+
1 Framework error Runner threw, unreadable evidence, etc.
|
|
1427
|
+
2 FAIL (detected) classification=detected OR rwep ≥ escalate cap.
|
|
1428
|
+
3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
|
|
1429
|
+
4 Blocked Result returned ok:false (preflight halt).
|
|
1430
|
+
5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
|
|
1431
|
+
8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
|
|
1432
|
+
attestation lock after the bounded retry budget
|
|
1433
|
+
(~1-2s). Distinct from 1 so callers can retry the
|
|
1434
|
+
operation rather than treat it as a hard failure.
|
|
1435
|
+
Surfaces as body.lock_contention=true,
|
|
1436
|
+
body.exit_code=8.
|
|
1437
|
+
6-7 — reserved (6=TAMPERED on attest verifier; 7 unused)`,
|
|
1337
1438
|
ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
|
|
1338
1439
|
|
|
1339
1440
|
Flags:
|
|
1340
1441
|
--domain <id> Playbook ID (overrides submission.playbook_id).
|
|
1341
1442
|
--directive <id> Directive ID (overrides submission.directive_id).
|
|
1342
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.
|
|
1343
1452
|
--pretty Indented JSON output.`,
|
|
1344
1453
|
reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
|
|
1345
1454
|
|
|
@@ -1350,7 +1459,12 @@ Args / flags:
|
|
|
1350
1459
|
--since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
|
|
1351
1460
|
--pretty Indented JSON output.
|
|
1352
1461
|
|
|
1353
|
-
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)`,
|
|
1354
1468
|
"list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
|
|
1355
1469
|
|
|
1356
1470
|
Args / flags:
|
|
@@ -1380,7 +1494,12 @@ Subverbs:
|
|
|
1380
1494
|
for an explicit pair. Reports unchanged | drifted |
|
|
1381
1495
|
resolved per evidence_hash + classification deltas.
|
|
1382
1496
|
|
|
1383
|
-
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)`,
|
|
1384
1503
|
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
1385
1504
|
|
|
1386
1505
|
Replaces: scan + dispatch + recommend.
|
|
@@ -1435,6 +1554,14 @@ Flags:
|
|
|
1435
1554
|
--directive <id> Specific directive (default: first one).
|
|
1436
1555
|
--no-stream Single-shot mode: emit all phases as one JSON doc
|
|
1437
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.
|
|
1438
1565
|
--pretty Indented JSON output (single-shot only).
|
|
1439
1566
|
|
|
1440
1567
|
Stdin event grammar (one JSON object per line):
|
|
@@ -1493,6 +1620,11 @@ Flags:
|
|
|
1493
1620
|
summary (5-field digest), markdown (human digest).
|
|
1494
1621
|
Bundles (csaf-2.0/sarif/openvex) live on per-run
|
|
1495
1622
|
attestations, not the aggregate ci verdict.
|
|
1623
|
+
--csaf-status <s> CSAF tracking.status threaded into per-run bundles.
|
|
1624
|
+
One of: draft | interim (default) | final.
|
|
1625
|
+
--publisher-namespace <url>
|
|
1626
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1627
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1496
1628
|
--json Force single-line JSON (overrides any TTY heuristics).
|
|
1497
1629
|
--pretty Indented JSON output (implies --json).
|
|
1498
1630
|
|
|
@@ -1510,6 +1642,13 @@ Exit codes:
|
|
|
1510
1642
|
close.notification_actions entry started a
|
|
1511
1643
|
regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
|
|
1512
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.
|
|
1513
1652
|
|
|
1514
1653
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
1515
1654
|
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
@@ -1562,7 +1701,15 @@ Flags: --pretty.`,
|
|
|
1562
1701
|
Identical exit-code and output contract as \`run --all\`. Maintained for
|
|
1563
1702
|
operators who script the verb form rather than the flag.
|
|
1564
1703
|
|
|
1565
|
-
|
|
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.`,
|
|
1566
1713
|
};
|
|
1567
1714
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
1568
1715
|
}
|
|
@@ -1964,13 +2111,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1964
2111
|
// Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
|
|
1965
2112
|
// a bare `exceptd run` (no positional, no flags) which auto-detects scopes
|
|
1966
2113
|
// from the cwd.
|
|
1967
|
-
//
|
|
1968
|
-
//
|
|
1969
|
-
//
|
|
1970
|
-
//
|
|
1971
|
-
//
|
|
1972
|
-
//
|
|
1973
|
-
// 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.
|
|
1974
2120
|
if (!positional && (args.all || args.scope !== undefined)) {
|
|
1975
2121
|
let ids;
|
|
1976
2122
|
if (args.all) {
|
|
@@ -2048,20 +2194,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2048
2194
|
// v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
|
|
2049
2195
|
// pipe, assume `--evidence -`. Operators forgetting the flag previously
|
|
2050
2196
|
// got a confusing precondition halt; now the common case "just works."
|
|
2051
|
-
//
|
|
2052
|
-
//
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
//
|
|
2056
|
-
//
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
// the raw `!process.stdin.isTTY` truthy check. Test harnesses with
|
|
2060
|
-
// wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
|
|
2061
|
-
// isTTY === undefined but have no data — the raw check fell into
|
|
2062
|
-
// readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
|
|
2063
|
-
// hasReadableStdin() does an fstat() probe first, then falls back to
|
|
2064
|
-
// the truthy check only on Windows (where fstat on a pipe is unreliable).
|
|
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.
|
|
2065
2205
|
if (!args.evidence && hasReadableStdin()) {
|
|
2066
2206
|
args.evidence = "-";
|
|
2067
2207
|
}
|
|
@@ -2097,11 +2237,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2097
2237
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
2098
2238
|
if (args.vex) {
|
|
2099
2239
|
let vexDoc;
|
|
2100
|
-
//
|
|
2101
|
-
//
|
|
2102
|
-
//
|
|
2103
|
-
//
|
|
2104
|
-
// 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
|
|
2105
2244
|
// any legitimate VEX submission.
|
|
2106
2245
|
const MAX_VEX_BYTES = 32 * 1024 * 1024;
|
|
2107
2246
|
let vstat;
|
|
@@ -2110,7 +2249,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2110
2249
|
return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2111
2250
|
}
|
|
2112
2251
|
if (vstat.size > MAX_VEX_BYTES) {
|
|
2113
|
-
//
|
|
2252
|
+
// Error message names the binary mebi convention explicitly so
|
|
2114
2253
|
// operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
|
|
2115
2254
|
return emitError(
|
|
2116
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.`,
|
|
@@ -2119,14 +2258,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2119
2258
|
);
|
|
2120
2259
|
}
|
|
2121
2260
|
try {
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
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
|
|
2124
2263
|
// readFileSync chokes on the leading 0xFEFF.
|
|
2125
2264
|
vexDoc = readJsonFile(args.vex);
|
|
2126
2265
|
} catch (e) {
|
|
2127
2266
|
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2128
2267
|
}
|
|
2129
|
-
//
|
|
2268
|
+
// Validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
|
|
2130
2269
|
// The runner tolerantly returns Set(0) for anything that's not CycloneDX
|
|
2131
2270
|
// or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
|
|
2132
2271
|
// advisory by mistake got ZERO filter applied and ZERO feedback. Now:
|
|
@@ -2144,15 +2283,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2144
2283
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
2145
2284
|
submission.signals = submission.signals || {};
|
|
2146
2285
|
submission.signals.vex_filter = [...vexSet];
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
2149
|
-
//
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
//
|
|
2154
|
-
// the CLI. The bundle-correctness tests only exercised the analyze()
|
|
2155
|
-
// 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.
|
|
2156
2293
|
submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
|
|
2157
2294
|
} catch (e) {
|
|
2158
2295
|
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
@@ -2191,14 +2328,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2191
2328
|
// ack state by that name (`result.ack` is shorter + matches the CLI flag).
|
|
2192
2329
|
if (result && runOpts.operator) result.operator = runOpts.operator;
|
|
2193
2330
|
|
|
2194
|
-
//
|
|
2195
|
-
//
|
|
2331
|
+
// --ack consent only counts when a jurisdiction clock is actually at
|
|
2332
|
+
// stake — i.e. the run produced classification=detected (a real finding
|
|
2196
2333
|
// that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
|
|
2197
|
-
// not-detected or inconclusive run, persisting the consent
|
|
2198
|
-
//
|
|
2199
|
-
//
|
|
2200
|
-
//
|
|
2201
|
-
//
|
|
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.
|
|
2202
2339
|
const detectClassification = result && result.phases && result.phases.detect
|
|
2203
2340
|
? result.phases.detect.classification
|
|
2204
2341
|
: null;
|
|
@@ -2223,7 +2360,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2223
2360
|
directiveId: result.directive_id,
|
|
2224
2361
|
evidenceHash: result.evidence_hash,
|
|
2225
2362
|
operator: runOpts.operator,
|
|
2226
|
-
//
|
|
2363
|
+
// Gate consent persistence on classification=detected.
|
|
2227
2364
|
operatorConsent: consentApplies ? runOpts.operator_consent : null,
|
|
2228
2365
|
submission,
|
|
2229
2366
|
runOpts,
|
|
@@ -2231,10 +2368,17 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2231
2368
|
filename: "attestation.json",
|
|
2232
2369
|
});
|
|
2233
2370
|
if (!persistResult.ok) {
|
|
2234
|
-
// Session-id collision without --force-overwrite
|
|
2235
|
-
//
|
|
2236
|
-
//
|
|
2237
|
-
// is a tamper-evidence
|
|
2371
|
+
// Session-id collision without --force-overwrite, OR --force-overwrite
|
|
2372
|
+
// lost the lockfile race. Refuse, surface the existing path so the
|
|
2373
|
+
// operator can decide, emit JSON to stderr matching the unified error
|
|
2374
|
+
// shape. Exit non-zero — a silent overwrite is a tamper-evidence
|
|
2375
|
+
// violation. v0.12.14: exitCode + return instead of process.exit so
|
|
2376
|
+
// the stderr line drains under piped CI consumers.
|
|
2377
|
+
//
|
|
2378
|
+
// When persistAttestation lost the lockfile race it pinned
|
|
2379
|
+
// process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
|
|
2380
|
+
// overwrite that with 3 — preserve the exit-8 contract callers depend
|
|
2381
|
+
// on to distinguish lock-busy from collision.
|
|
2238
2382
|
const err = {
|
|
2239
2383
|
ok: false,
|
|
2240
2384
|
error: persistResult.error,
|
|
@@ -2242,10 +2386,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2242
2386
|
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
2243
2387
|
verb: "run",
|
|
2244
2388
|
};
|
|
2245
|
-
|
|
2246
|
-
|
|
2389
|
+
if (persistResult.lock_contention) {
|
|
2390
|
+
err.lock_contention = true;
|
|
2391
|
+
err.exit_code = 8;
|
|
2392
|
+
}
|
|
2247
2393
|
process.stderr.write(JSON.stringify(err) + "\n");
|
|
2248
|
-
|
|
2394
|
+
if (!persistResult.lock_contention) {
|
|
2395
|
+
process.exitCode = 3;
|
|
2396
|
+
}
|
|
2249
2397
|
return;
|
|
2250
2398
|
}
|
|
2251
2399
|
if (persistResult.prior_session_id) {
|
|
@@ -2259,12 +2407,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2259
2407
|
}
|
|
2260
2408
|
|
|
2261
2409
|
if (result && result.ok === false) {
|
|
2262
|
-
//
|
|
2263
|
-
//
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
// (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).
|
|
2268
2415
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
2269
2416
|
process.exitCode = args.ci ? 4 : 1;
|
|
2270
2417
|
return;
|
|
@@ -2472,9 +2619,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2472
2619
|
const top = rwep?.threshold?.escalate ?? "n/a";
|
|
2473
2620
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
2474
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`);
|
|
2475
|
-
// F11: surface --diff-from-latest verdict in the human renderer
|
|
2476
|
-
// operators
|
|
2477
|
-
//
|
|
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.
|
|
2478
2626
|
// - unchanged: same evidence_hash as prior → reassuring single line.
|
|
2479
2627
|
// - drifted: evidence differs → loud DRIFTED marker.
|
|
2480
2628
|
// - no_prior_attestation_for_playbook: no line — don't clutter the
|
|
@@ -2552,7 +2700,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2552
2700
|
* inconclusive findings + visibility gaps) when no --evidence is given.
|
|
2553
2701
|
*/
|
|
2554
2702
|
/**
|
|
2555
|
-
*
|
|
2703
|
+
* Collapse per-playbook notification_actions into a deduped rollup.
|
|
2556
2704
|
* Multi-playbook runs frequently surface the same jurisdiction clock from
|
|
2557
2705
|
* 5-10 contributing playbooks (every EU-touching playbook starts a fresh
|
|
2558
2706
|
* NIS2 Art.23 24h clock). Operators were drafting one notification per
|
|
@@ -2587,12 +2735,11 @@ function buildJurisdictionClockRollup(results) {
|
|
|
2587
2735
|
existing.deadline = n.deadline;
|
|
2588
2736
|
}
|
|
2589
2737
|
} else {
|
|
2590
|
-
//
|
|
2591
|
-
// entry promised) AND retain `obligation_ref` as a kept-name alias
|
|
2738
|
+
// Emit `obligation` and retain `obligation_ref` as a kept-name alias
|
|
2592
2739
|
// for any consumer that already parses the older shape. The dedupe
|
|
2593
2740
|
// key still keys on n.obligation_ref since that's the field
|
|
2594
2741
|
// notification-action stubs carry; the rollup body just exposes
|
|
2595
|
-
// both names
|
|
2742
|
+
// both names.
|
|
2596
2743
|
const obligation = n.obligation_ref || null;
|
|
2597
2744
|
m.set(key, {
|
|
2598
2745
|
jurisdiction: n.jurisdiction || null,
|
|
@@ -2646,13 +2793,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2646
2793
|
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
2647
2794
|
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
2648
2795
|
}
|
|
2649
|
-
//
|
|
2650
|
-
//
|
|
2651
|
-
//
|
|
2652
|
-
//
|
|
2653
|
-
//
|
|
2654
|
-
//
|
|
2655
|
-
//
|
|
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.
|
|
2656
2803
|
let lst;
|
|
2657
2804
|
try { lst = fs.lstatSync(entryPath); }
|
|
2658
2805
|
catch (e) {
|
|
@@ -2664,9 +2811,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2664
2811
|
if (!lst.isFile()) {
|
|
2665
2812
|
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
|
|
2666
2813
|
}
|
|
2667
|
-
//
|
|
2814
|
+
// Windows directory junctions are reparse-point dirs that
|
|
2668
2815
|
// `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
|
|
2669
|
-
// ordinary directories)
|
|
2816
|
+
// ordinary directories), bypassing the symlink refusal above. Use
|
|
2670
2817
|
// realpathSync to resolve the entry and confirm it still lives under
|
|
2671
2818
|
// the resolved evidence-dir — the realpath approach is portable
|
|
2672
2819
|
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
@@ -2683,10 +2830,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2683
2830
|
pretty
|
|
2684
2831
|
);
|
|
2685
2832
|
}
|
|
2686
|
-
//
|
|
2687
|
-
//
|
|
2688
|
-
//
|
|
2689
|
-
//
|
|
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 —
|
|
2690
2837
|
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
2691
2838
|
// nlink > 1 without malicious intent.
|
|
2692
2839
|
if (lst.nlink > 1) {
|
|
@@ -2714,6 +2861,29 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2714
2861
|
|
|
2715
2862
|
const result = runner.run(id, directiveId, submission, perRunOpts);
|
|
2716
2863
|
|
|
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.
|
|
2871
|
+
const perDetectClassification = result && result.phases && result.phases.detect
|
|
2872
|
+
? result.phases.detect.classification
|
|
2873
|
+
: null;
|
|
2874
|
+
const perConsentApplies =
|
|
2875
|
+
!!perRunOpts.operator_consent && perDetectClassification === "detected";
|
|
2876
|
+
if (result && perRunOpts.operator_consent) {
|
|
2877
|
+
result.operator_consent = perRunOpts.operator_consent;
|
|
2878
|
+
result.ack = !!perRunOpts.operator_consent.explicit;
|
|
2879
|
+
result.ack_applied = perConsentApplies;
|
|
2880
|
+
if (!perConsentApplies) {
|
|
2881
|
+
result.ack_skipped_reason = `classification=${perDetectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
2882
|
+
}
|
|
2883
|
+
} else if (result) {
|
|
2884
|
+
result.ack = false;
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2717
2887
|
// Persist per-playbook attestation under the shared session.
|
|
2718
2888
|
if (result && result.ok) {
|
|
2719
2889
|
const persisted = persistAttestation({
|
|
@@ -2722,7 +2892,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2722
2892
|
directiveId,
|
|
2723
2893
|
evidenceHash: result.evidence_hash,
|
|
2724
2894
|
operator: perRunOpts.operator,
|
|
2725
|
-
|
|
2895
|
+
// Gate consent persistence on this playbook's classification, not
|
|
2896
|
+
// on the aggregate run's --ack presence.
|
|
2897
|
+
operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
|
|
2726
2898
|
submission,
|
|
2727
2899
|
runOpts: perRunOpts,
|
|
2728
2900
|
forceOverwrite: !!args["force-overwrite"],
|
|
@@ -2740,12 +2912,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2740
2912
|
results.push(result);
|
|
2741
2913
|
}
|
|
2742
2914
|
|
|
2743
|
-
//
|
|
2744
|
-
// results into a single rollup.
|
|
2745
|
-
// contributors of "EU NIS2 Art.23 24h"
|
|
2746
|
-
// operators
|
|
2747
|
-
// playbook entries are preserved on individual results; this rollup
|
|
2748
|
-
// 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,
|
|
2749
2921
|
// window_hours) — with a triggered_by_playbooks[] list so operators see
|
|
2750
2922
|
// which playbooks contributed.
|
|
2751
2923
|
const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
|
|
@@ -2779,19 +2951,14 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2779
2951
|
// `ingest` matches the AGENTS.md ingest contract. The submission JSON may
|
|
2780
2952
|
// carry playbook_id + directive_id; --domain/--directive flags override.
|
|
2781
2953
|
let submission = {};
|
|
2782
|
-
//
|
|
2783
|
-
// `echo '{...}' | exceptd ingest`
|
|
2784
|
-
//
|
|
2785
|
-
//
|
|
2786
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
// detects piped stdin") was a no-op on Windows pre-fix.
|
|
2791
|
-
//
|
|
2792
|
-
// EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
|
|
2793
|
-
// Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
|
|
2794
|
-
// would otherwise block here forever on the readFileSync(0) call.
|
|
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.
|
|
2795
2962
|
if (!args.evidence && hasReadableStdin()) {
|
|
2796
2963
|
args.evidence = "-";
|
|
2797
2964
|
}
|
|
@@ -2830,21 +2997,42 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2830
2997
|
// calls with the same session-id silently clobbered the audit trail and no
|
|
2831
2998
|
// .sig sidecar was written.
|
|
2832
2999
|
if (result && result.ok && result.session_id) {
|
|
3000
|
+
// Mirror cmdRun / cmdRunMulti: gate operator_consent persistence on
|
|
3001
|
+
// classification === 'detected'. --ack is meaningful only when a
|
|
3002
|
+
// jurisdiction clock is at stake; persisting consent on a
|
|
3003
|
+
// not-detected ingest forges audit-trail consent for a clock that
|
|
3004
|
+
// never started.
|
|
3005
|
+
const ingestClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
|
|
3006
|
+
const ingestConsentApplies = ingestClassification === "detected";
|
|
3007
|
+
if (runOpts.operator_consent && !ingestConsentApplies) {
|
|
3008
|
+
result.ack = true;
|
|
3009
|
+
result.ack_applied = false;
|
|
3010
|
+
result.ack_skipped_reason = `classification=${ingestClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
3011
|
+
}
|
|
2833
3012
|
const persisted = persistAttestation({
|
|
2834
3013
|
sessionId: result.session_id,
|
|
2835
3014
|
playbookId: result.playbook_id,
|
|
2836
3015
|
directiveId: result.directive_id,
|
|
2837
3016
|
evidenceHash: result.evidence_hash,
|
|
2838
3017
|
operator: runOpts.operator,
|
|
2839
|
-
operatorConsent: runOpts.operator_consent,
|
|
3018
|
+
operatorConsent: ingestConsentApplies ? runOpts.operator_consent : null,
|
|
2840
3019
|
submission: cleanedSubmission,
|
|
2841
3020
|
runOpts,
|
|
2842
3021
|
forceOverwrite: !!args["force-overwrite"],
|
|
2843
3022
|
filename: "attestation.json",
|
|
2844
3023
|
});
|
|
2845
3024
|
if (!persisted.ok) {
|
|
2846
|
-
// Surface the collision; do not silently clobber.
|
|
2847
|
-
|
|
3025
|
+
// Surface the collision; do not silently clobber. Preserve
|
|
3026
|
+
// LOCK_CONTENTION exit 8 set by persistAttestation when
|
|
3027
|
+
// --force-overwrite hit the lockfile race.
|
|
3028
|
+
const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
|
|
3029
|
+
if (persisted.lock_contention) {
|
|
3030
|
+
ctx.lock_contention = true;
|
|
3031
|
+
ctx.exit_code = 8;
|
|
3032
|
+
process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
return emitError(persisted.error, ctx, pretty);
|
|
2848
3036
|
}
|
|
2849
3037
|
if (persisted.prior_session_id) {
|
|
2850
3038
|
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
@@ -2991,21 +3179,21 @@ function persistAttestation(args) {
|
|
|
2991
3179
|
existingPath: path.relative(process.cwd(), filePath),
|
|
2992
3180
|
};
|
|
2993
3181
|
}
|
|
2994
|
-
//
|
|
2995
|
-
//
|
|
2996
|
-
//
|
|
2997
|
-
//
|
|
2998
|
-
//
|
|
2999
|
-
//
|
|
3000
|
-
// DD P1-2: MAX_RETRIES capped at 10
|
|
3001
|
-
//
|
|
3002
|
-
//
|
|
3003
|
-
//
|
|
3004
|
-
//
|
|
3005
|
-
//
|
|
3006
|
-
//
|
|
3007
|
-
//
|
|
3008
|
-
//
|
|
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.
|
|
3009
3197
|
const lockPath = filePath + ".lock";
|
|
3010
3198
|
const MAX_RETRIES = 10;
|
|
3011
3199
|
const STALE_LOCK_MS = 30_000;
|
|
@@ -3044,16 +3232,25 @@ function persistAttestation(args) {
|
|
|
3044
3232
|
}
|
|
3045
3233
|
}
|
|
3046
3234
|
if (!acquired) {
|
|
3047
|
-
//
|
|
3048
|
-
// genuine lock-busy condition (retry-from-outside is
|
|
3049
|
-
// from a hard failure (write error, permission
|
|
3050
|
-
// spin budget
|
|
3051
|
-
//
|
|
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.
|
|
3240
|
+
//
|
|
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().
|
|
3247
|
+
process.exitCode = 8;
|
|
3052
3248
|
return {
|
|
3053
3249
|
ok: false,
|
|
3054
3250
|
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
3251
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3056
3252
|
lock_contention: true,
|
|
3253
|
+
exit_code: 8,
|
|
3057
3254
|
};
|
|
3058
3255
|
}
|
|
3059
3256
|
try {
|
|
@@ -3091,7 +3288,7 @@ function persistAttestation(args) {
|
|
|
3091
3288
|
* from "the .sig file was deleted by an attacker."
|
|
3092
3289
|
*/
|
|
3093
3290
|
/**
|
|
3094
|
-
*
|
|
3291
|
+
* Byte-stability normalize() for the attestation pipeline.
|
|
3095
3292
|
* Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
|
|
3096
3293
|
* normalize() implementations in lib/sign.js, lib/verify.js,
|
|
3097
3294
|
* lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
|
|
@@ -3117,13 +3314,13 @@ function maybeSignAttestation(filePath) {
|
|
|
3117
3314
|
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
3118
3315
|
// verifier doesn't trust.
|
|
3119
3316
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3120
|
-
//
|
|
3121
|
-
//
|
|
3317
|
+
// Normalize attestation bytes before sign — strip leading UTF-8 BOM +
|
|
3318
|
+
// collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
|
|
3122
3319
|
// lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
|
|
3123
3320
|
// attestation file lives on disk under .exceptd/ and can pick up CRLF
|
|
3124
3321
|
// through git-attribute / editor round-trips on Windows; without
|
|
3125
3322
|
// normalization the sign/verify pair diverges on the same logical content.
|
|
3126
|
-
// The byte-stability contract
|
|
3323
|
+
// The byte-stability contract spans five sites; tests/normalize-contract
|
|
3127
3324
|
// .test.js enforces byte-identical output across all of them.
|
|
3128
3325
|
const rawContent = fs.readFileSync(filePath, "utf8");
|
|
3129
3326
|
const content = normalizeAttestationBytes(rawContent);
|
|
@@ -3147,20 +3344,24 @@ function maybeSignAttestation(filePath) {
|
|
|
3147
3344
|
key: privateKey,
|
|
3148
3345
|
dsaEncoding: "ieee-p1363",
|
|
3149
3346
|
});
|
|
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.
|
|
3150
3356
|
fs.writeFileSync(sigPath, JSON.stringify({
|
|
3151
3357
|
algorithm: "Ed25519",
|
|
3152
3358
|
signature_base64: sig.toString("base64"),
|
|
3153
|
-
|
|
3154
|
-
signs_path: path.basename(filePath),
|
|
3155
|
-
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
3359
|
+
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
3360
|
}, null, 2));
|
|
3157
3361
|
} else {
|
|
3158
3362
|
fs.writeFileSync(sigPath, JSON.stringify({
|
|
3159
3363
|
algorithm: "unsigned",
|
|
3160
3364
|
signed: false,
|
|
3161
|
-
signed_at: null,
|
|
3162
|
-
signs_path: path.basename(filePath),
|
|
3163
|
-
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
3164
3365
|
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
3366
|
}, null, 2));
|
|
3166
3367
|
}
|
|
@@ -3244,6 +3445,12 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
3244
3445
|
try {
|
|
3245
3446
|
const p = path.join(sdir, f);
|
|
3246
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;
|
|
3247
3454
|
if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
|
|
3248
3455
|
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
3249
3456
|
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
@@ -3254,14 +3461,14 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
3254
3461
|
}
|
|
3255
3462
|
|
|
3256
3463
|
/**
|
|
3257
|
-
*
|
|
3258
|
-
*
|
|
3464
|
+
* Factored Ed25519-sidecar verification used by both `attest verify` and
|
|
3465
|
+
* `reattest`. Returns { file, signed, verified, reason } for a given
|
|
3259
3466
|
* attestation file path.
|
|
3260
3467
|
*
|
|
3261
|
-
*
|
|
3262
|
-
*
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
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.
|
|
3265
3472
|
*/
|
|
3266
3473
|
function verifyAttestationSidecar(attFile) {
|
|
3267
3474
|
const crypto = require("crypto");
|
|
@@ -3287,7 +3494,7 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3287
3494
|
let sigDoc;
|
|
3288
3495
|
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
3289
3496
|
catch (e) {
|
|
3290
|
-
//
|
|
3497
|
+
// a corrupt-JSON sidecar is observationally indistinguishable
|
|
3291
3498
|
// from sidecar tamper — an attacker who can rewrite attestation.json can
|
|
3292
3499
|
// also truncate / mangle the .sig file. Surface as a distinct
|
|
3293
3500
|
// tamper-class reason so callers can require --force-replay. Pre-fix,
|
|
@@ -3303,7 +3510,7 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3303
3510
|
};
|
|
3304
3511
|
}
|
|
3305
3512
|
if (sigDoc.algorithm === "unsigned") {
|
|
3306
|
-
//
|
|
3513
|
+
// `algorithm: "unsigned"` is only legitimate when written
|
|
3307
3514
|
// by maybeSignAttestation() at attestation-creation time on a host
|
|
3308
3515
|
// WITHOUT .keys/private.pem. If the verifying host HAS a private key,
|
|
3309
3516
|
// an "unsigned" sidecar is a substitution attack: tamper attestation.json
|
|
@@ -3321,15 +3528,33 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3321
3528
|
}
|
|
3322
3529
|
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3323
3530
|
}
|
|
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.
|
|
3540
|
+
if (sigDoc.algorithm !== "Ed25519") {
|
|
3541
|
+
return {
|
|
3542
|
+
file: attFile,
|
|
3543
|
+
signed: false,
|
|
3544
|
+
verified: false,
|
|
3545
|
+
reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
|
|
3546
|
+
tamper_class: "algorithm-unsupported",
|
|
3547
|
+
};
|
|
3548
|
+
}
|
|
3324
3549
|
if (!pubKey) {
|
|
3325
3550
|
return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
3326
3551
|
}
|
|
3327
3552
|
let content;
|
|
3328
3553
|
try {
|
|
3329
3554
|
const raw = fs.readFileSync(attFile, "utf8");
|
|
3330
|
-
//
|
|
3331
|
-
//
|
|
3332
|
-
//
|
|
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).
|
|
3333
3558
|
content = normalizeAttestationBytes(raw);
|
|
3334
3559
|
}
|
|
3335
3560
|
catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
|
|
@@ -3349,13 +3574,14 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3349
3574
|
}
|
|
3350
3575
|
|
|
3351
3576
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
3352
|
-
|
|
3353
|
-
//
|
|
3354
|
-
// through to walkAttestationDir, where
|
|
3355
|
-
//
|
|
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.
|
|
3356
3581
|
if (args.since != null) {
|
|
3357
|
-
//
|
|
3358
|
-
// otherwise parse as the year 1999 and silently filter wrong
|
|
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.
|
|
3359
3585
|
const sinceErr = validateIsoSince(args.since);
|
|
3360
3586
|
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
3361
3587
|
}
|
|
@@ -3379,24 +3605,33 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3379
3605
|
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
3380
3606
|
}
|
|
3381
3607
|
|
|
3382
|
-
//
|
|
3383
|
-
//
|
|
3384
|
-
// verdict
|
|
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
|
|
3385
3611
|
// with exit 6 (TAMPERED) unless --force-replay is explicitly set.
|
|
3386
3612
|
// Unsigned attestations (no private key was available at run time) emit
|
|
3387
3613
|
// a stderr warning but proceed — that's an operator config issue, not
|
|
3388
3614
|
// tampering. `verified === false && signed === true` is the real tamper
|
|
3389
3615
|
// signal.
|
|
3390
3616
|
const verify = verifyAttestationSidecar(attFile);
|
|
3391
|
-
//
|
|
3392
|
-
//
|
|
3393
|
-
//
|
|
3394
|
-
//
|
|
3395
|
-
//
|
|
3396
|
-
//
|
|
3397
|
-
// 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.
|
|
3398
3623
|
const isSignedTamper = verify.signed && !verify.verified;
|
|
3399
|
-
const isClassTamper = !verify.signed && (
|
|
3624
|
+
const isClassTamper = !verify.signed && (
|
|
3625
|
+
verify.tamper_class === "sidecar-corrupt"
|
|
3626
|
+
|| verify.tamper_class === "unsigned-substitution"
|
|
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.
|
|
3633
|
+
|| verify.tamper_class === "algorithm-unsupported"
|
|
3634
|
+
);
|
|
3400
3635
|
if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
|
|
3401
3636
|
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
3637
|
const body = {
|
|
@@ -3441,7 +3676,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3441
3676
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
|
|
3442
3677
|
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
3678
|
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && !args["force-replay"]) {
|
|
3444
|
-
//
|
|
3679
|
+
// legitimately-unsigned attestations (written when the
|
|
3445
3680
|
// attesting host had no private key) require --force-replay to consume.
|
|
3446
3681
|
// Pre-fix, the NOTE branch accepted them silently — which let an
|
|
3447
3682
|
// attacker swap a valid .sig with the unsigned stub on a host that
|
|
@@ -3520,6 +3755,85 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3520
3755
|
}
|
|
3521
3756
|
}
|
|
3522
3757
|
|
|
3758
|
+
const replayedAt = new Date().toISOString();
|
|
3759
|
+
const sidecarVerifyClass = classifySidecarVerify(verify);
|
|
3760
|
+
const forceReplay = !!args["force-replay"];
|
|
3761
|
+
|
|
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).
|
|
3771
|
+
//
|
|
3772
|
+
// Filename shape: ISO-8601 uses ':' which the persistAttestation regex
|
|
3773
|
+
// refuses; substitute ':' with '-' and keep millisecond precision so
|
|
3774
|
+
// multiple replays in the same second do not collide on EEXIST. The
|
|
3775
|
+
// resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.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
|
+
};
|
|
3793
|
+
let replayPersisted = null;
|
|
3794
|
+
let replayPath = null;
|
|
3795
|
+
try {
|
|
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 };
|
|
3818
|
+
} catch (e) {
|
|
3819
|
+
// Non-fatal — stdout emit is the operator's primary surface; a
|
|
3820
|
+
// disk-persistence failure shouldn't mask the verdict. Surface the
|
|
3821
|
+
// condition in the response body so an operator-side audit pipeline
|
|
3822
|
+
// can re-run the persist later.
|
|
3823
|
+
replayPersisted = { ok: false, error: String((e && e.message) || e) };
|
|
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
|
+
}
|
|
3836
|
+
|
|
3523
3837
|
emit({
|
|
3524
3838
|
ok: true,
|
|
3525
3839
|
verb: "reattest",
|
|
@@ -3530,13 +3844,13 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3530
3844
|
prior_evidence_hash: priorHash,
|
|
3531
3845
|
replay_evidence_hash: newHash,
|
|
3532
3846
|
prior_captured_at: prior.captured_at,
|
|
3533
|
-
replayed_at:
|
|
3847
|
+
replayed_at: replayedAt,
|
|
3534
3848
|
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
3535
3849
|
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
3536
|
-
//
|
|
3850
|
+
// Persist the sidecar verify result + the force-replay flag so the
|
|
3537
3851
|
// audit trail records whether the replay was authenticated input.
|
|
3538
3852
|
sidecar_verify: verify,
|
|
3539
|
-
//
|
|
3853
|
+
// emit a one-token classification label alongside the
|
|
3540
3854
|
// full sidecar_verify object so log scrapers / dashboards can filter on
|
|
3541
3855
|
// override events without parsing reason strings. Values:
|
|
3542
3856
|
// 'verified' — Ed25519 sidecar verified
|
|
@@ -3544,16 +3858,22 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3544
3858
|
// 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
|
|
3545
3859
|
// 'unsigned-substitution'— "unsigned" sidecar on a host with private key
|
|
3546
3860
|
// (substitution attack signal)
|
|
3861
|
+
// 'algorithm-unsupported'— sidecar algorithm field is neither "Ed25519"
|
|
3862
|
+
// nor "unsigned" (downgrade-bait substitution)
|
|
3547
3863
|
// 'explicitly-unsigned' — legitimately-unsigned attestation
|
|
3548
3864
|
// 'no-sidecar' — sidecar file absent
|
|
3549
3865
|
// 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
|
|
3550
|
-
sidecar_verify_class:
|
|
3551
|
-
force_replay:
|
|
3866
|
+
sidecar_verify_class: sidecarVerifyClass,
|
|
3867
|
+
force_replay: forceReplay,
|
|
3868
|
+
// Surface the persisted replay-record path (or persistence failure
|
|
3869
|
+
// reason) so an auditor reading the CLI response can locate the
|
|
3870
|
+
// on-disk artifact without re-deriving the filename.
|
|
3871
|
+
replay_persisted: replayPersisted,
|
|
3552
3872
|
}, pretty);
|
|
3553
3873
|
}
|
|
3554
3874
|
|
|
3555
3875
|
/**
|
|
3556
|
-
*
|
|
3876
|
+
* map a verifyAttestationSidecar() result to a one-token
|
|
3557
3877
|
* classification label. The label is persisted alongside the full
|
|
3558
3878
|
* sidecar_verify object so auditors can filter override events by class
|
|
3559
3879
|
* without regexing the human-readable reason string.
|
|
@@ -3564,6 +3884,9 @@ function classifySidecarVerify(verify) {
|
|
|
3564
3884
|
if (verify.signed && !verify.verified) return "tampered";
|
|
3565
3885
|
if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
|
|
3566
3886
|
if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
|
|
3887
|
+
// `algorithm-unsupported` is its own class label so log scrapers /
|
|
3888
|
+
// dashboards can filter downgrade-bait events without parsing the reason.
|
|
3889
|
+
if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
|
|
3567
3890
|
if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
|
|
3568
3891
|
if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
|
|
3569
3892
|
if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
|
|
@@ -3595,12 +3918,12 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3595
3918
|
if (!sessionId) {
|
|
3596
3919
|
return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
|
|
3597
3920
|
}
|
|
3598
|
-
//
|
|
3599
|
-
//
|
|
3600
|
-
//
|
|
3601
|
-
//
|
|
3602
|
-
//
|
|
3603
|
-
//
|
|
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.
|
|
3604
3927
|
try { validateSessionIdForRead(sessionId); }
|
|
3605
3928
|
catch (e) {
|
|
3606
3929
|
return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
|
|
@@ -3611,13 +3934,24 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3611
3934
|
}
|
|
3612
3935
|
|
|
3613
3936
|
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
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
|
+
}
|
|
3618
3952
|
|
|
3619
3953
|
if (subverb === "show") {
|
|
3620
|
-
emit({ session_id: sessionId, attestations }, pretty);
|
|
3954
|
+
emit({ session_id: sessionId, attestations, attestation_replays: replays }, pretty);
|
|
3621
3955
|
return;
|
|
3622
3956
|
}
|
|
3623
3957
|
|
|
@@ -3632,10 +3966,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3632
3966
|
return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
|
|
3633
3967
|
}
|
|
3634
3968
|
const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3635
|
-
|
|
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) {
|
|
3636
3983
|
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
3637
3984
|
}
|
|
3638
|
-
const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
|
|
3639
3985
|
const self = attestations[0];
|
|
3640
3986
|
emit({
|
|
3641
3987
|
verb: "attest diff",
|
|
@@ -3689,7 +4035,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3689
4035
|
pretty
|
|
3690
4036
|
);
|
|
3691
4037
|
}
|
|
3692
|
-
//
|
|
4038
|
+
// on the verifying host, detect "unsigned" sidecar
|
|
3693
4039
|
// substitution by checking whether .keys/private.pem is present. A
|
|
3694
4040
|
// legitimately-unsigned attestation cannot coexist with a private key on
|
|
3695
4041
|
// the same host — that combination is sidecar substitution (attacker
|
|
@@ -3699,7 +4045,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3699
4045
|
const results = files.map(f => {
|
|
3700
4046
|
const sigPath = path.join(dir, f + ".sig");
|
|
3701
4047
|
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
3702
|
-
//
|
|
4048
|
+
// wrap JSON.parse so a corrupt sidecar surfaces as a
|
|
3703
4049
|
// structured tamper-class result (signed:false, verified:false,
|
|
3704
4050
|
// tamper_class:"sidecar-corrupt") rather than throwing into the outer
|
|
3705
4051
|
// dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
|
|
@@ -3717,7 +4063,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3717
4063
|
};
|
|
3718
4064
|
}
|
|
3719
4065
|
if (sigDoc.algorithm === "unsigned") {
|
|
3720
|
-
//
|
|
4066
|
+
// substitution detection.
|
|
3721
4067
|
if (hasPrivKey) {
|
|
3722
4068
|
return {
|
|
3723
4069
|
file: f,
|
|
@@ -3729,9 +4075,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3729
4075
|
}
|
|
3730
4076
|
return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3731
4077
|
}
|
|
4078
|
+
// Strict algorithm check (mirrors verifyAttestationSidecar). Anything
|
|
4079
|
+
// that isn't exactly "Ed25519" or "unsigned" is refused as
|
|
4080
|
+
// tamper-class; null / "RSA-PSS" / arrays would otherwise fall through
|
|
4081
|
+
// to crypto.verify with Ed25519 defaults, producing either an opaque
|
|
4082
|
+
// verify-throw or a downgrade-bait acceptance path.
|
|
4083
|
+
if (sigDoc.algorithm !== "Ed25519") {
|
|
4084
|
+
return {
|
|
4085
|
+
file: f,
|
|
4086
|
+
signed: false,
|
|
4087
|
+
verified: false,
|
|
4088
|
+
reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
|
|
4089
|
+
tamper_class: "algorithm-unsupported",
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
3732
4092
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
3733
|
-
//
|
|
3734
|
-
//
|
|
4093
|
+
// Normalize before crypto.verify — mirrors the signer path so the
|
|
4094
|
+
// verify pair is byte-stable across CRLF / BOM churn.
|
|
3735
4095
|
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
3736
4096
|
const content = normalizeAttestationBytes(rawContent);
|
|
3737
4097
|
try {
|
|
@@ -3743,23 +4103,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3743
4103
|
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
3744
4104
|
}
|
|
3745
4105
|
});
|
|
3746
|
-
//
|
|
3747
|
-
//
|
|
3748
|
-
//
|
|
3749
|
-
//
|
|
3750
|
-
// signal even when an attestation had been forged. emit()'s ok:false
|
|
3751
|
-
// → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
|
|
3752
|
-
// 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.
|
|
3753
4110
|
//
|
|
3754
|
-
//
|
|
3755
|
-
//
|
|
3756
|
-
//
|
|
3757
|
-
//
|
|
3758
|
-
//
|
|
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.
|
|
3759
4116
|
const tampered = results.some(r =>
|
|
3760
4117
|
(r.signed && !r.verified)
|
|
3761
4118
|
|| r.tamper_class === "sidecar-corrupt"
|
|
3762
4119
|
|| r.tamper_class === "unsigned-substitution"
|
|
4120
|
+
// A sidecar whose algorithm field is not "Ed25519" or "unsigned" is
|
|
4121
|
+
// a downgrade-bait substitution; promote to exit 6.
|
|
4122
|
+
|| r.tamper_class === "algorithm-unsupported"
|
|
3763
4123
|
);
|
|
3764
4124
|
const body = { verb: "attest verify", session_id: sessionId, results };
|
|
3765
4125
|
if (tampered) {
|
|
@@ -4561,8 +4921,9 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4561
4921
|
// Prior behavior silently accepted any string and lexically compared to
|
|
4562
4922
|
// captured_at, producing 0-result or full-result depending on the string.
|
|
4563
4923
|
if (args.since != null) {
|
|
4564
|
-
//
|
|
4565
|
-
// otherwise parse as the year 1999 and silently filter wrong
|
|
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.
|
|
4566
4927
|
const sinceErr = validateIsoSince(args.since);
|
|
4567
4928
|
if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
|
|
4568
4929
|
}
|
|
@@ -4583,6 +4944,11 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4583
4944
|
for (const f of files) {
|
|
4584
4945
|
try {
|
|
4585
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;
|
|
4586
4952
|
// v0.12.14: normalized array-set filter (see top of fn).
|
|
4587
4953
|
if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
|
|
4588
4954
|
if (args.since && (j.captured_at || "") < args.since) continue;
|
|
@@ -4660,7 +5026,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4660
5026
|
directPhase = runner.direct(playbookId, directiveId);
|
|
4661
5027
|
lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
4662
5028
|
} catch (e) {
|
|
4663
|
-
//
|
|
5029
|
+
// process.exit(1) immediately after a stdout write can
|
|
4664
5030
|
// truncate buffered output under piped consumers (same class as v0.11.10
|
|
4665
5031
|
// #100). Use exitCode+return so the JSONL error frame drains. Also write
|
|
4666
5032
|
// the framed error event so the stdout-only JSONL contract holds — host
|
|
@@ -4709,7 +5075,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4709
5075
|
try { payload = readEvidence(args.evidence); }
|
|
4710
5076
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
4711
5077
|
} else if (hasReadableStdin()) {
|
|
4712
|
-
//
|
|
5078
|
+
// hasReadableStdin() probes via fstat before falling into
|
|
4713
5079
|
// readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
|
|
4714
5080
|
// size===0) would otherwise hang here.
|
|
4715
5081
|
// Drain stdin for any evidence event.
|
|
@@ -4758,13 +5124,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4758
5124
|
// or `reattest` with the returned id. Now: same persistAttestation
|
|
4759
5125
|
// shape as cmdRun, so AI-facing flow round-trips cleanly.
|
|
4760
5126
|
if (result.session_id) {
|
|
5127
|
+
// Mirror cmdRun: gate operator_consent on classification === 'detected'.
|
|
5128
|
+
const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
|
|
5129
|
+
const aiConsentApplies = aiClassification === "detected";
|
|
5130
|
+
if (runOpts.operator_consent && !aiConsentApplies) {
|
|
5131
|
+
result.ack = true;
|
|
5132
|
+
result.ack_applied = false;
|
|
5133
|
+
result.ack_skipped_reason = `classification=${aiClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
5134
|
+
}
|
|
4761
5135
|
const persistResult = persistAttestation({
|
|
4762
5136
|
sessionId: result.session_id,
|
|
4763
5137
|
playbookId: result.playbook_id || playbookId,
|
|
4764
5138
|
directiveId: result.directive_id || directiveId,
|
|
4765
5139
|
evidenceHash: result.evidence_hash,
|
|
4766
5140
|
operator: runOpts.operator,
|
|
4767
|
-
operatorConsent: runOpts.operator_consent,
|
|
5141
|
+
operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
|
|
4768
5142
|
submission,
|
|
4769
5143
|
runOpts,
|
|
4770
5144
|
forceOverwrite: !!args["force-overwrite"],
|
|
@@ -4773,12 +5147,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4773
5147
|
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
4774
5148
|
// Collision without --force-overwrite. AI agents typically pass
|
|
4775
5149
|
// unique session ids each run, so this path is rare but surface
|
|
4776
|
-
// it cleanly via the same JSONL contract.
|
|
4777
|
-
|
|
5150
|
+
// it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
|
|
5151
|
+
// exit 8 set by persistAttestation when --force-overwrite hit the
|
|
5152
|
+
// lockfile race — don't clobber with exit 3.
|
|
5153
|
+
const eventBody = {
|
|
4778
5154
|
event: "error", reason: persistResult.error,
|
|
4779
5155
|
existing_attestation: persistResult.existingPath,
|
|
4780
|
-
}
|
|
4781
|
-
|
|
5156
|
+
};
|
|
5157
|
+
if (persistResult.lock_contention) {
|
|
5158
|
+
eventBody.lock_contention = true;
|
|
5159
|
+
eventBody.exit_code = 8;
|
|
5160
|
+
}
|
|
5161
|
+
process.stdout.write(JSON.stringify(eventBody) + "\n");
|
|
5162
|
+
if (!persistResult.lock_contention) {
|
|
5163
|
+
process.exitCode = 3;
|
|
5164
|
+
}
|
|
4782
5165
|
return;
|
|
4783
5166
|
}
|
|
4784
5167
|
}
|
|
@@ -4862,21 +5245,31 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4862
5245
|
// too. Without this, the session_id emitted in the `done` frame
|
|
4863
5246
|
// can't be resolved by `attest show / verify / diff` or `reattest`.
|
|
4864
5247
|
if (result.session_id) {
|
|
5248
|
+
// Mirror cmdRun: gate operator_consent on classification === 'detected'.
|
|
5249
|
+
const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
|
|
5250
|
+
const aiConsentApplies = aiClassification === "detected";
|
|
4865
5251
|
const persistResult = persistAttestation({
|
|
4866
5252
|
sessionId: result.session_id,
|
|
4867
5253
|
playbookId: result.playbook_id || playbookId,
|
|
4868
5254
|
directiveId: result.directive_id || directiveId,
|
|
4869
5255
|
evidenceHash: result.evidence_hash,
|
|
4870
5256
|
operator: runOpts.operator,
|
|
4871
|
-
operatorConsent: runOpts.operator_consent,
|
|
5257
|
+
operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
|
|
4872
5258
|
submission,
|
|
4873
5259
|
runOpts,
|
|
4874
5260
|
forceOverwrite: !!args["force-overwrite"],
|
|
4875
5261
|
filename: "attestation.json",
|
|
4876
5262
|
});
|
|
4877
5263
|
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
4878
|
-
|
|
4879
|
-
|
|
5264
|
+
const eventBody = { event: "error", reason: persistResult.error,
|
|
5265
|
+
existing_attestation: persistResult.existingPath };
|
|
5266
|
+
if (persistResult.lock_contention) {
|
|
5267
|
+
eventBody.lock_contention = true;
|
|
5268
|
+
eventBody.exit_code = 8;
|
|
5269
|
+
writeLine(eventBody);
|
|
5270
|
+
return finish(8);
|
|
5271
|
+
}
|
|
5272
|
+
writeLine(eventBody);
|
|
4880
5273
|
return finish(3);
|
|
4881
5274
|
}
|
|
4882
5275
|
}
|
|
@@ -5176,9 +5569,9 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5176
5569
|
const results = [];
|
|
5177
5570
|
let fail = false;
|
|
5178
5571
|
let failReasons = [];
|
|
5179
|
-
//
|
|
5180
|
-
// exit code can distinguish "detected/escalated" (2) from "regulatory
|
|
5181
|
-
// running, operator must notify" (5).
|
|
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).
|
|
5182
5575
|
let clockStartedFail = false;
|
|
5183
5576
|
let clockStartedReasons = [];
|
|
5184
5577
|
|
|
@@ -5232,11 +5625,10 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5232
5625
|
failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
|
|
5233
5626
|
}
|
|
5234
5627
|
if (blockOnClock && clockStarted) {
|
|
5235
|
-
//
|
|
5236
|
-
//
|
|
5237
|
-
// detected" from "regulatory
|
|
5238
|
-
//
|
|
5239
|
-
// 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."
|
|
5240
5632
|
clockStartedFail = true;
|
|
5241
5633
|
clockStartedReasons.push(`${id}: jurisdiction clock started`);
|
|
5242
5634
|
}
|
|
@@ -5256,7 +5648,7 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5256
5648
|
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
5257
5649
|
const totalForVerdict = results.length;
|
|
5258
5650
|
const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
|
|
5259
|
-
//
|
|
5651
|
+
// Precedence: BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
|
|
5260
5652
|
// CLOCK_STARTED outranks FAIL because the operator explicitly opted into
|
|
5261
5653
|
// the clock gate (--block-on-jurisdiction-clock); when that gate fires,
|
|
5262
5654
|
// they want the regulatory-deadline signal even if a detected finding
|
|
@@ -5312,11 +5704,11 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5312
5704
|
.filter(n => n && n.clock_started_at != null).length,
|
|
5313
5705
|
framework_gap_rollup: frameworkGapRollup,
|
|
5314
5706
|
framework_gap_count: frameworkGapRollup.length,
|
|
5315
|
-
//
|
|
5316
|
-
// buildJurisdictionClockRollup.
|
|
5317
|
-
// one notification entry per contributing playbook (often 8+)
|
|
5318
|
-
// single notification per (jurisdiction, regulation,
|
|
5319
|
-
// window)
|
|
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.
|
|
5320
5712
|
jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
|
|
5321
5713
|
verdict: computedVerdict,
|
|
5322
5714
|
fail_reasons: failReasons,
|
|
@@ -5380,7 +5772,7 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5380
5772
|
process.exitCode = 4;
|
|
5381
5773
|
return;
|
|
5382
5774
|
}
|
|
5383
|
-
//
|
|
5775
|
+
// Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
|
|
5384
5776
|
// --block-on-jurisdiction-clock; when a clock fires, that's the gate
|
|
5385
5777
|
// result they want to see at the exit-code layer. Per-playbook detected
|
|
5386
5778
|
// findings remain in the body for them to investigate.
|