@blamejs/exceptd-skills 0.12.20 → 0.12.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -6
- package/bin/exceptd.js +835 -70
- package/data/_indexes/_meta.json +14 -14
- package/data/_indexes/activity-feed.json +3 -3
- package/data/_indexes/catalog-summaries.json +3 -3
- package/data/_indexes/chains.json +15 -0
- package/data/_indexes/jurisdiction-map.json +3 -2
- package/data/_indexes/section-offsets.json +175 -175
- package/data/_indexes/summary-cards.json +1 -1
- package/data/_indexes/token-budget.json +83 -83
- package/data/cve-catalog.json +169 -2
- package/data/exploit-availability.json +16 -0
- package/data/playbooks/ai-api.json +18 -0
- package/data/playbooks/containers.json +30 -0
- package/data/playbooks/cred-stores.json +18 -0
- package/data/playbooks/crypto.json +18 -0
- package/data/playbooks/hardening.json +26 -1
- package/data/playbooks/kernel.json +22 -2
- package/data/playbooks/mcp.json +18 -0
- package/data/playbooks/runtime.json +22 -1
- package/data/playbooks/sbom.json +18 -0
- package/data/playbooks/secrets.json +6 -0
- package/data/zeroday-lessons.json +102 -0
- package/lib/auto-discovery.js +9 -9
- package/lib/cross-ref-api.js +43 -10
- package/lib/cve-curation.js +4 -4
- package/lib/playbook-runner.js +529 -70
- package/lib/prefetch.js +3 -3
- package/lib/refresh-external.js +13 -2
- package/lib/refresh-network.js +22 -17
- package/lib/scoring.js +22 -13
- package/lib/sign.js +5 -5
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +2 -2
- package/lib/validate-indexes.js +2 -2
- package/lib/verify.js +63 -13
- package/manifest.json +47 -47
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/check-manifest-snapshot.js +1 -1
- package/scripts/check-sbom-currency.js +1 -1
- package/scripts/predeploy.js +6 -6
- package/scripts/refresh-manifest-snapshot.js +2 -2
- package/scripts/validate-vendor-online.js +1 -1
- package/scripts/verify-shipped-tarball.js +15 -12
- package/skills/compliance-theater/skill.md +4 -1
- package/skills/exploit-scoring/skill.md +20 -1
- package/skills/framework-gap-analysis/skill.md +6 -2
- package/skills/kernel-lpe-triage/skill.md +50 -3
- package/skills/threat-model-currency/skill.md +7 -5
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +44 -1
package/bin/exceptd.js
CHANGED
|
@@ -59,7 +59,7 @@ const { spawnSync } = require("child_process");
|
|
|
59
59
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
62
|
+
* Factor the EXPECTED_FINGERPRINT pin check used by
|
|
63
63
|
* the attestation pipeline. Centralizes the policy (compute live SHA-256
|
|
64
64
|
* fingerprint of the loaded public.pem, compare to keys/EXPECTED_FINGERPRINT,
|
|
65
65
|
* honor KEYS_ROTATED=1 bypass, tolerate missing pin file) so every site
|
|
@@ -85,8 +85,12 @@ function assertExpectedFingerprint(pubKeyPem) {
|
|
|
85
85
|
} catch (e) {
|
|
86
86
|
return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
|
|
87
87
|
}
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
|
|
89
|
+
// pin file (Notepad with files.encoding=utf8bom) is tolerated identically
|
|
90
|
+
// across every verify site. The helper strips leading U+FEFF + ignores
|
|
91
|
+
// comment lines.
|
|
92
|
+
const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
|
|
93
|
+
const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
|
|
90
94
|
if (firstLine === liveFp) return null;
|
|
91
95
|
if (process.env.KEYS_ROTATED === "1") return null;
|
|
92
96
|
return (
|
|
@@ -607,7 +611,7 @@ function emit(obj, pretty, humanRenderer) {
|
|
|
607
611
|
}
|
|
608
612
|
|
|
609
613
|
function emitError(msg, extra, pretty) {
|
|
610
|
-
// v0.12.14
|
|
614
|
+
// v0.12.14: the v0.11.13 emit() fix used exitCode + return
|
|
611
615
|
// to defend stdout-buffered writes from truncation under piped consumers.
|
|
612
616
|
// emitError() (stderr) kept process.exit(1), which has the same truncation
|
|
613
617
|
// class — CLAUDE.md's "fix the class, not the instance." Now: write to
|
|
@@ -619,6 +623,64 @@ function emitError(msg, extra, pretty) {
|
|
|
619
623
|
process.exitCode = 1;
|
|
620
624
|
}
|
|
621
625
|
|
|
626
|
+
/**
|
|
627
|
+
* EE P1-2: shared BOM-tolerant JSON file reader. Windows tools commonly emit
|
|
628
|
+
* UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
|
|
629
|
+
* `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
|
|
630
|
+
* becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
|
|
631
|
+
* UTF-16 as garbage. Route every operator-supplied JSON file through here.
|
|
632
|
+
*
|
|
633
|
+
* 1. read as Buffer
|
|
634
|
+
* 2. detect BOM (UTF-16 LE / BE / UTF-8 BOM)
|
|
635
|
+
* 3. decode appropriately, strip leading BOM if present
|
|
636
|
+
* 4. JSON.parse
|
|
637
|
+
*
|
|
638
|
+
* On parse failure, throw a clean message that preserves the operator-facing
|
|
639
|
+
* path but does NOT leak the raw V8 parser stack — operators see "failed to
|
|
640
|
+
* parse JSON at <path>: <reason>", not a 12-line trace.
|
|
641
|
+
*/
|
|
642
|
+
function readJsonFile(filePath) {
|
|
643
|
+
let buf;
|
|
644
|
+
try { buf = fs.readFileSync(filePath); }
|
|
645
|
+
catch (e) { throw new Error(`failed to read ${filePath}: ${e.message}`); }
|
|
646
|
+
let text;
|
|
647
|
+
if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xFE) {
|
|
648
|
+
text = buf.slice(2).toString("utf16le");
|
|
649
|
+
} else if (buf.length >= 2 && buf[0] === 0xFE && buf[1] === 0xFF) {
|
|
650
|
+
// UTF-16 BE: Node has no native decoder. Swap byte pairs to LE, then decode.
|
|
651
|
+
//
|
|
652
|
+
// refuse odd-length payloads up front rather than carry
|
|
653
|
+
// the trailing byte through a partial swap. A UTF-16BE payload by
|
|
654
|
+
// definition has an even byte count after the BOM; odd-length input is
|
|
655
|
+
// either truncated or not UTF-16BE at all.
|
|
656
|
+
//
|
|
657
|
+
// use Buffer.alloc (zero-initialised) instead of
|
|
658
|
+
// Buffer.allocUnsafe so an unexpected loop bound never lets uninitialised
|
|
659
|
+
// heap bytes leak into the decoded string and downstream JSON.parse
|
|
660
|
+
// error message.
|
|
661
|
+
const payloadLength = buf.length - 2;
|
|
662
|
+
if (payloadLength % 2 !== 0) {
|
|
663
|
+
throw new Error(`failed to read ${filePath}: UTF-16BE payload must have an even byte count after BOM; got ${payloadLength} bytes — file may be truncated.`);
|
|
664
|
+
}
|
|
665
|
+
const swapped = Buffer.alloc(payloadLength);
|
|
666
|
+
for (let i = 2; i < buf.length - 1; i += 2) {
|
|
667
|
+
swapped[i - 2] = buf[i + 1];
|
|
668
|
+
swapped[i - 1] = buf[i];
|
|
669
|
+
}
|
|
670
|
+
text = swapped.toString("utf16le");
|
|
671
|
+
} else if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
|
|
672
|
+
text = buf.slice(3).toString("utf8");
|
|
673
|
+
} else {
|
|
674
|
+
text = buf.toString("utf8");
|
|
675
|
+
}
|
|
676
|
+
// Belt-and-braces: strip any residual leading U+FEFF the decode may have left.
|
|
677
|
+
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
|
|
678
|
+
try { return JSON.parse(text); }
|
|
679
|
+
catch (e) {
|
|
680
|
+
throw new Error(`failed to parse JSON at ${filePath}: ${e.message}`);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
622
684
|
function readEvidence(evidenceFlag) {
|
|
623
685
|
if (!evidenceFlag) return {};
|
|
624
686
|
if (evidenceFlag === "-") {
|
|
@@ -637,13 +699,84 @@ function readEvidence(evidenceFlag) {
|
|
|
637
699
|
if (stat.size > MAX_EVIDENCE_BYTES) {
|
|
638
700
|
throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
|
|
639
701
|
}
|
|
640
|
-
|
|
702
|
+
// EE P1-2: route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
|
|
703
|
+
// Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
|
|
704
|
+
// decode in readFileSync chokes on the leading 0xFEFF.
|
|
705
|
+
return readJsonFile(evidenceFlag);
|
|
641
706
|
}
|
|
642
707
|
|
|
643
708
|
function loadRunner() {
|
|
644
709
|
return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
|
|
645
710
|
}
|
|
646
711
|
|
|
712
|
+
/**
|
|
713
|
+
* EE P1-7: detect whether stdin actually has data without blocking.
|
|
714
|
+
*
|
|
715
|
+
* `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
|
|
716
|
+
* `false`, `undefined`, OR `null`. Test harnesses with custom stdin
|
|
717
|
+
* duplexers (Mocha/Jest, some Docker stdin-passthrough wrappers) leave
|
|
718
|
+
* isTTY === undefined but never write any bytes — falling into
|
|
719
|
+
* `fs.readFileSync(0, "utf8")` then BLOCKS waiting for an EOF that
|
|
720
|
+
* never arrives.
|
|
721
|
+
*
|
|
722
|
+
* Strategy:
|
|
723
|
+
*
|
|
724
|
+
* 1. If isTTY is truthy → operator is at a terminal, never read stdin.
|
|
725
|
+
* 2. POSIX: trust isFIFO / isSocket / isCharacterDevice. Regular file
|
|
726
|
+
* requires size > 0 (empty file redirection should not be treated
|
|
727
|
+
* as piped input).
|
|
728
|
+
* 3. Windows: `isTTY === false` strict (filters out wrapped test
|
|
729
|
+
* duplexers which leave isTTY === undefined). DO NOT gate on size
|
|
730
|
+
* because Windows pipes report as regular files with size 0 even
|
|
731
|
+
* when bytes are queued — gating would silently skip every
|
|
732
|
+
* `echo {...} | exceptd run` invocation.
|
|
733
|
+
* 4. If a wrapped test harness on Windows does want stdin auto-read
|
|
734
|
+
* to skip, the harness must set `process.stdin.isTTY = undefined`
|
|
735
|
+
* explicitly (Mocha/Jest do this by default).
|
|
736
|
+
*
|
|
737
|
+
* Returns `true` if the caller may safely fs.readFileSync(0) without
|
|
738
|
+
* risking an indefinite block on a wrapped empty stream.
|
|
739
|
+
*/
|
|
740
|
+
function hasReadableStdin() {
|
|
741
|
+
if (process.stdin.isTTY) return false;
|
|
742
|
+
let st;
|
|
743
|
+
try { st = fs.fstatSync(0); }
|
|
744
|
+
catch {
|
|
745
|
+
// KK P1-4: fstat failed — tighten the Windows fallback to require
|
|
746
|
+
// `isTTY === false` STRICTLY (not falsy). Pre-fix `!process.stdin.isTTY`
|
|
747
|
+
// returned true when isTTY was undefined (Mocha/Jest test harness with
|
|
748
|
+
// wrapped duplexer on Windows), so the caller called `fs.readFileSync(0)`
|
|
749
|
+
// and blocked indefinitely waiting on an EOF that never came. The legacy
|
|
750
|
+
// MSYS-bash piping scenario (R-F3 in v0.12.16) sets isTTY === false on
|
|
751
|
+
// win32 when piped, so the strict check preserves that working case.
|
|
752
|
+
if (process.platform === "win32") return process.stdin.isTTY === false;
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
755
|
+
// POSIX pipes / FIFOs / sockets / character devices report size 0
|
|
756
|
+
// even when bytes are queued (or about to be). Trust them — a real
|
|
757
|
+
// `echo '{...}' | exceptd run` pipeline lands here, and readFileSync(0)
|
|
758
|
+
// will read to EOF cleanly. If the write end is open and no bytes
|
|
759
|
+
// arrive, the read blocks — that's the operator's contract, not the
|
|
760
|
+
// CLI's to second-guess. Wrapped test harnesses that never write
|
|
761
|
+
// should pass `--evidence -` explicitly.
|
|
762
|
+
if (typeof st.isFIFO === "function" && st.isFIFO()) return true;
|
|
763
|
+
if (typeof st.isSocket === "function" && st.isSocket()) return true;
|
|
764
|
+
if (typeof st.isCharacterDevice === "function" && st.isCharacterDevice()) return true;
|
|
765
|
+
// Regular file (e.g. `exceptd run <evidence.json` shell redirect).
|
|
766
|
+
// size 0 here means a legitimately empty file.
|
|
767
|
+
if (typeof st.size === "number" && st.size > 0) return true;
|
|
768
|
+
// Windows fallback: pipes don't surface as FIFOs via fstat on win32
|
|
769
|
+
// (they appear as regular files with size 0 even when bytes queued).
|
|
770
|
+
// Trust isTTY === false strictly — that filters out wrapped test
|
|
771
|
+
// duplexers (which leave isTTY === undefined) while keeping cmd.exe /
|
|
772
|
+
// PowerShell / MSYS pipes working (isTTY === false when piped). Do NOT
|
|
773
|
+
// gate on size > 0 here: a Windows pipe with bytes queued reports as
|
|
774
|
+
// a regular file with size 0, and gating would silently skip every
|
|
775
|
+
// `echo {...} | exceptd run|ingest|ai-run` invocation.
|
|
776
|
+
if (process.platform === "win32" && process.stdin.isTTY === false) return true;
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
|
|
647
780
|
/**
|
|
648
781
|
* R-F10: ISO-8601 shape regex applied BEFORE Date.parse. Pre-fix, both
|
|
649
782
|
* `attest list --since` and `reattest --since` accepted anything Date.parse
|
|
@@ -717,8 +850,17 @@ function detectVexShape(doc) {
|
|
|
717
850
|
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
718
851
|
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
719
852
|
}
|
|
720
|
-
|
|
721
|
-
|
|
853
|
+
// EE P1-1: a CycloneDX SBOM with no `vulnerabilities` key is a legitimate
|
|
854
|
+
// "0-CVE VEX filter" submission — the operator is asserting nothing here is
|
|
855
|
+
// exploitable. Accept it as cyclonedx-vex with an empty filter set (the
|
|
856
|
+
// runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
|
|
857
|
+
// for documents that carry a CycloneDX-flavored specVersion ("1.x") without
|
|
858
|
+
// bomFormat — Windows tooling sometimes drops the marker on export.
|
|
859
|
+
const cyclonedxMarker =
|
|
860
|
+
doc.bomFormat === "CycloneDX" ||
|
|
861
|
+
(typeof doc.specVersion === "string" && /^1\./.test(doc.specVersion));
|
|
862
|
+
if (cyclonedxMarker && !Array.isArray(doc.vulnerabilities)) {
|
|
863
|
+
return { ok: true, detected: "cyclonedx-vex-zero-cve", top_level_keys: keys };
|
|
722
864
|
}
|
|
723
865
|
if (Array.isArray(doc.statements) && !ctxStr) {
|
|
724
866
|
return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
|
|
@@ -863,13 +1005,164 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
863
1005
|
pretty
|
|
864
1006
|
);
|
|
865
1007
|
}
|
|
866
|
-
|
|
1008
|
+
// EE P1-3: the ASCII-only control-char regex above misses Unicode
|
|
1009
|
+
// categories Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
|
|
1010
|
+
// zero-width joiners (U+200B-D), invisible format chars, private-use
|
|
1011
|
+
// codepoints, unassigned codepoints. An operator string like
|
|
1012
|
+
// "aliceevilbob" renders as "alicebobevila" in any UI that respects
|
|
1013
|
+
// bidi — a forgery surface where the attested name looks like Bob but the
|
|
1014
|
+
// bytes are Alice. Reject anything outside a positive allowlist of
|
|
1015
|
+
// printable ASCII + most BMP printable codepoints (skipping the format /
|
|
1016
|
+
// control / surrogate gaps).
|
|
1017
|
+
//
|
|
1018
|
+
// Implementation: NFC-normalise first (so a decomposed sequence can't
|
|
1019
|
+
// smuggle a combining mark past the codepoint check), then iterate
|
|
1020
|
+
// codepoints and refuse Cc/Cf/Co/Cn. We use \p{C} via the `u` regex flag,
|
|
1021
|
+
// which matches Cc + Cf + Cs + Co + Cn in one shot. Unicode 15.1 is the
|
|
1022
|
+
// baseline supported by Node 20.
|
|
1023
|
+
let normalized;
|
|
1024
|
+
try { normalized = args.operator.normalize("NFC"); }
|
|
1025
|
+
catch (e) {
|
|
1026
|
+
return emitError(
|
|
1027
|
+
`run: --operator failed Unicode NFC normalisation: ${e.message}`,
|
|
1028
|
+
{ provided_length: args.operator.length },
|
|
1029
|
+
pretty
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
if (normalized.length === 0) {
|
|
1033
|
+
return emitError(
|
|
1034
|
+
"run: --operator is empty after Unicode NFC normalisation. Pass a meaningful identifier or omit the flag.",
|
|
1035
|
+
null,
|
|
1036
|
+
pretty
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
if (/\p{C}/u.test(normalized)) {
|
|
1040
|
+
// Find the offending codepoint to surface a useful hint without
|
|
1041
|
+
// round-tripping the raw bytes into the error body.
|
|
1042
|
+
let offending = "";
|
|
1043
|
+
for (const cp of normalized) {
|
|
1044
|
+
if (/\p{C}/u.test(cp)) {
|
|
1045
|
+
offending = "U+" + cp.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
|
|
1046
|
+
break;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
return emitError(
|
|
1050
|
+
`run: --operator contains a Unicode control / format / private-use / unassigned codepoint (${offending}). Bidi overrides (U+202E), zero-width joiners (U+200B–D), and format marks corrupt attestation rendering and enable name-forgery. Use printable identifiers only.`,
|
|
1051
|
+
{ provided_length: args.operator.length, offending_codepoint: offending },
|
|
1052
|
+
pretty
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
runOpts.operator = normalized;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// NN P1-1 / P1-2 / P1-5: --csaf-status and --publisher-namespace shape the
|
|
1059
|
+
// CSAF bundle emitted by phases 5-7. Verbs that don't drive those phases
|
|
1060
|
+
// (brief, plan, govern, direct, look, attest, list-attestations, discover,
|
|
1061
|
+
// doctor, lint, ask, verify-attestation, reattest) never assemble a
|
|
1062
|
+
// bundle, so silently consuming these flags is a UX trap. Refuse on those
|
|
1063
|
+
// verbs so the operator knows the flag was discarded — same pattern as
|
|
1064
|
+
// EE P1-6 closed for --ack. Error message templates and emitError prefixes
|
|
1065
|
+
// use the in-scope `cmd` verb so a brief invocation says "brief:" rather
|
|
1066
|
+
// than misattributing the flag to run.
|
|
1067
|
+
const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
|
|
1068
|
+
"run", "ci", "run-all", "ai-run", "ingest",
|
|
1069
|
+
]);
|
|
1070
|
+
|
|
1071
|
+
// --publisher-namespace <url> threads into the CSAF
|
|
1072
|
+
// bundle's document.publisher.namespace field. CSAF §3.1.7.4 requires the
|
|
1073
|
+
// namespace to be the publisher's trust anchor — i.e. the OPERATOR
|
|
1074
|
+
// running the scan, not the tooling vendor. Pre-fix this was hard-coded
|
|
1075
|
+
// to https://exceptd.com, misattributing responsibility for advisory
|
|
1076
|
+
// accuracy. Validation mirrors --operator (string, ≤256 chars, no
|
|
1077
|
+
// ASCII / Unicode control characters), plus a URL-shape check (`^https?:`).
|
|
1078
|
+
if (args["publisher-namespace"] !== undefined) {
|
|
1079
|
+
if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
|
|
1080
|
+
return emitError(
|
|
1081
|
+
`${cmd}: --publisher-namespace is irrelevant on this verb (no CSAF bundle is assembled). --publisher-namespace only applies to verbs that drive phases 5-7: ${[...BUNDLE_FLAG_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --publisher-namespace, or pass it on \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --publisher-namespace <url>\` once you're past the briefing step.`,
|
|
1082
|
+
{ verb: cmd, flag: "publisher-namespace", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
|
|
1083
|
+
pretty
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
const ns = args["publisher-namespace"];
|
|
1087
|
+
if (typeof ns !== "string") {
|
|
1088
|
+
return emitError(`${cmd}: --publisher-namespace must be a string.`, { provided: typeof ns }, pretty);
|
|
1089
|
+
}
|
|
1090
|
+
// eslint-disable-next-line no-control-regex
|
|
1091
|
+
if (/[\x00-\x1F\x7F]/.test(ns)) {
|
|
1092
|
+
return emitError(
|
|
1093
|
+
`${cmd}: --publisher-namespace contains ASCII control characters. Refusing — these would corrupt CSAF rendering and break URL parsing in downstream consumers.`,
|
|
1094
|
+
{ provided_length: ns.length },
|
|
1095
|
+
pretty
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (ns.length === 0 || ns.length > 256) {
|
|
1099
|
+
return emitError(
|
|
1100
|
+
`${cmd}: --publisher-namespace length ${ns.length} out of bounds (1–256).`,
|
|
1101
|
+
{ provided_length: ns.length },
|
|
1102
|
+
pretty
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
if (!/^https?:\/\//i.test(ns)) {
|
|
1106
|
+
return emitError(
|
|
1107
|
+
`${cmd}: --publisher-namespace must be a URL starting with http:// or https:// (e.g. https://your-org.example). CSAF §3.1.7.4 requires the namespace to be the publisher's trust anchor.`,
|
|
1108
|
+
{ provided: ns.slice(0, 80) },
|
|
1109
|
+
pretty
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
runOpts.publisherNamespace = ns;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// --csaf-status promotes the CSAF tracking.status from the
|
|
1116
|
+
// runtime default (`interim`) to `final` for operators who have reviewed
|
|
1117
|
+
// the advisory and accept the immutable-advisory contract of CSAF
|
|
1118
|
+
// §3.1.11.3.5.1. Accepts the three CSAF spec values; anything else is
|
|
1119
|
+
// rejected at input so an operator typo (`finel`) doesn't silently fall
|
|
1120
|
+
// back to interim and produce surprise.
|
|
1121
|
+
if (args["csaf-status"] !== undefined) {
|
|
1122
|
+
if (!BUNDLE_FLAG_RELEVANT_VERBS.has(cmd)) {
|
|
1123
|
+
return emitError(
|
|
1124
|
+
`${cmd}: --csaf-status is irrelevant on this verb (no CSAF bundle is assembled). --csaf-status only applies to verbs that drive phases 5-7: ${[...BUNDLE_FLAG_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --csaf-status, or pass it on \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --csaf-status <status>\` once you're past the briefing step.`,
|
|
1125
|
+
{ verb: cmd, flag: "csaf-status", error_class: "irrelevant-flag", accepted_verbs: [...BUNDLE_FLAG_RELEVANT_VERBS].sort() },
|
|
1126
|
+
pretty
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
const cs = args["csaf-status"];
|
|
1130
|
+
const allowed = ["draft", "interim", "final"];
|
|
1131
|
+
if (typeof cs !== "string" || !allowed.includes(cs)) {
|
|
1132
|
+
return emitError(
|
|
1133
|
+
`${cmd}: --csaf-status must be one of ${JSON.stringify(allowed)}. Got: ${JSON.stringify(String(cs)).slice(0, 40)}`,
|
|
1134
|
+
{ provided: cs },
|
|
1135
|
+
pretty
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
runOpts.csafStatus = cs;
|
|
867
1139
|
}
|
|
1140
|
+
|
|
868
1141
|
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
869
1142
|
// govern. Captured in attestation; downstream tooling can check whether
|
|
870
1143
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
871
1144
|
// and wait for ack — this is how the ack gets recorded.
|
|
872
|
-
|
|
1145
|
+
//
|
|
1146
|
+
// EE P1-6: --ack only makes sense on verbs that drive phases 5-7 (run /
|
|
1147
|
+
// ingest / ai-run / ci / run-all / reattest). Info-only verbs (brief,
|
|
1148
|
+
// plan, govern, direct, look, attest, list-attestations, discover,
|
|
1149
|
+
// doctor, lint, ask, verify-attestation) never consume an attestation
|
|
1150
|
+
// clock — accepting --ack silently here was a UX trap where operators
|
|
1151
|
+
// believed they had recorded consent. Refuse on those verbs so the
|
|
1152
|
+
// operator knows the flag is irrelevant.
|
|
1153
|
+
const ACK_RELEVANT_VERBS = new Set([
|
|
1154
|
+
"run", "ingest", "ai-run", "ci", "run-all", "reattest",
|
|
1155
|
+
]);
|
|
1156
|
+
if (args.ack) {
|
|
1157
|
+
if (!ACK_RELEVANT_VERBS.has(cmd)) {
|
|
1158
|
+
return emitError(
|
|
1159
|
+
`${cmd}: --ack is irrelevant on this verb (no jurisdiction clock at stake). --ack only applies to verbs that drive phases 5-7: ${[...ACK_RELEVANT_VERBS].sort().join(", ")}. Re-invoke without --ack, or use \`exceptd run ${cmd === "brief" ? args._[0] || "<playbook>" : "<playbook>"} --ack\` once you're past the briefing step.`,
|
|
1160
|
+
{ verb: cmd, accepted_verbs: [...ACK_RELEVANT_VERBS].sort() },
|
|
1161
|
+
pretty
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
|
|
1165
|
+
}
|
|
873
1166
|
|
|
874
1167
|
let runner;
|
|
875
1168
|
try {
|
|
@@ -1074,6 +1367,15 @@ Flags:
|
|
|
1074
1367
|
--ack Mark explicit operator consent to the jurisdiction
|
|
1075
1368
|
obligations surfaced by govern. Persisted under
|
|
1076
1369
|
attestation.operator_consent.
|
|
1370
|
+
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1371
|
+
bundle. One of: draft | interim (default) | final.
|
|
1372
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1373
|
+
set this only after operator review of the advisory.
|
|
1374
|
+
--publisher-namespace <url>
|
|
1375
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1376
|
+
publisher trust anchor — i.e. the operator's
|
|
1377
|
+
organisation, NOT the tooling vendor. Must be an
|
|
1378
|
+
http://… or https://… URL, ≤256 chars.
|
|
1077
1379
|
--diff-from-latest Compare evidence_hash against the most recent prior
|
|
1078
1380
|
attestation for the same playbook in
|
|
1079
1381
|
.exceptd/attestations/. Emits status: unchanged | drifted.
|
|
@@ -1102,7 +1404,21 @@ Flags:
|
|
|
1102
1404
|
--pretty Indented JSON output.
|
|
1103
1405
|
|
|
1104
1406
|
Attestation is persisted to .exceptd/attestations/<session_id>/ on every
|
|
1105
|
-
successful run (single: attestation.json; multi: <playbook_id>.json)
|
|
1407
|
+
successful run (single: attestation.json; multi: <playbook_id>.json).
|
|
1408
|
+
|
|
1409
|
+
Exit codes (per-verb, post-run):
|
|
1410
|
+
0 PASS Run completed; classification clean, RWEP under cap.
|
|
1411
|
+
1 Framework error Runner threw, unreadable evidence, etc.
|
|
1412
|
+
2 FAIL (detected) classification=detected OR rwep ≥ escalate cap.
|
|
1413
|
+
3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
|
|
1414
|
+
4 Blocked Result returned ok:false (preflight halt).
|
|
1415
|
+
5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
|
|
1416
|
+
8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
|
|
1417
|
+
attestation lock after the bounded retry budget
|
|
1418
|
+
(~1-2s). Distinct from 1 so callers can retry the
|
|
1419
|
+
operation rather than treat it as a hard failure.
|
|
1420
|
+
Surfaces as body.lock_contention=true,
|
|
1421
|
+
body.exit_code=8.`,
|
|
1106
1422
|
ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
|
|
1107
1423
|
|
|
1108
1424
|
Flags:
|
|
@@ -1209,7 +1525,7 @@ Flags:
|
|
|
1209
1525
|
Stdin event grammar (one JSON object per line):
|
|
1210
1526
|
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
1211
1527
|
|
|
1212
|
-
Stdin acceptance contract
|
|
1528
|
+
Stdin acceptance contract:
|
|
1213
1529
|
In streaming mode, ai-run reads JSON-Lines from stdin until the FIRST
|
|
1214
1530
|
parseable {"event":"evidence","payload":{...}} line. That line wins:
|
|
1215
1531
|
subsequent evidence events on the same run are ignored (the handler
|
|
@@ -1262,6 +1578,11 @@ Flags:
|
|
|
1262
1578
|
summary (5-field digest), markdown (human digest).
|
|
1263
1579
|
Bundles (csaf-2.0/sarif/openvex) live on per-run
|
|
1264
1580
|
attestations, not the aggregate ci verdict.
|
|
1581
|
+
--csaf-status <s> CSAF tracking.status threaded into per-run bundles.
|
|
1582
|
+
One of: draft | interim (default) | final.
|
|
1583
|
+
--publisher-namespace <url>
|
|
1584
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1585
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1265
1586
|
--json Force single-line JSON (overrides any TTY heuristics).
|
|
1266
1587
|
--pretty Indented JSON output (implies --json).
|
|
1267
1588
|
|
|
@@ -1643,7 +1964,7 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
1643
1964
|
emit(plan, pretty);
|
|
1644
1965
|
}
|
|
1645
1966
|
|
|
1646
|
-
// v0.12.15
|
|
1967
|
+
// v0.12.15: --scope must validate against the accepted
|
|
1647
1968
|
// set. The prior shape silently returned [] for any unknown scope, which
|
|
1648
1969
|
// in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
|
|
1649
1970
|
// "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
|
|
@@ -1823,7 +2144,15 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1823
2144
|
// fired, making `echo '{...}' | exceptd run <pb>` silently behave like
|
|
1824
2145
|
// no-evidence on Windows. cmdAiRun's path (below) already uses the
|
|
1825
2146
|
// truthy form, so this brings cmdRun + cmdIngest to parity.
|
|
1826
|
-
|
|
2147
|
+
//
|
|
2148
|
+
// EE P1-7: use the fstat-probing hasReadableStdin() helper instead of
|
|
2149
|
+
// the raw `!process.stdin.isTTY` truthy check. Test harnesses with
|
|
2150
|
+
// wrapped stdin duplexers (Mocha/Jest, Docker stdin-passthrough) leave
|
|
2151
|
+
// isTTY === undefined but have no data — the raw check fell into
|
|
2152
|
+
// readFileSync(0) and BLOCKED waiting for an EOF that never arrived.
|
|
2153
|
+
// hasReadableStdin() does an fstat() probe first, then falls back to
|
|
2154
|
+
// the truthy check only on Windows (where fstat on a pipe is unreliable).
|
|
2155
|
+
if (!args.evidence && hasReadableStdin()) {
|
|
1827
2156
|
args.evidence = "-";
|
|
1828
2157
|
}
|
|
1829
2158
|
if (args.evidence) {
|
|
@@ -1859,10 +2188,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1859
2188
|
if (args.vex) {
|
|
1860
2189
|
let vexDoc;
|
|
1861
2190
|
// R-F5: cap --vex file size the same way readEvidence() caps --evidence
|
|
1862
|
-
// (32
|
|
1863
|
-
//
|
|
1864
|
-
//
|
|
1865
|
-
//
|
|
2191
|
+
// (32 MiB — binary mebibytes, i.e. 32 * 1024 * 1024 = 33,554,432 bytes).
|
|
2192
|
+
// Pre-fix, --vex did a raw readFileSync with no size check — an operator
|
|
2193
|
+
// passing a multi-GB file (binary log, JSON bomb, or accident) blocked
|
|
2194
|
+
// the event loop for minutes / OOM'd the process. 32 MiB is well beyond
|
|
2195
|
+
// any legitimate VEX submission.
|
|
1866
2196
|
const MAX_VEX_BYTES = 32 * 1024 * 1024;
|
|
1867
2197
|
let vstat;
|
|
1868
2198
|
try { vstat = fs.statSync(args.vex); }
|
|
@@ -1870,14 +2200,19 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1870
2200
|
return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1871
2201
|
}
|
|
1872
2202
|
if (vstat.size > MAX_VEX_BYTES) {
|
|
2203
|
+
// EE P1-4: error message names the binary mebi convention explicitly so
|
|
2204
|
+
// operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
|
|
1873
2205
|
return emitError(
|
|
1874
|
-
`run: --vex file too large: ${vstat.size} bytes
|
|
2206
|
+
`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.`,
|
|
1875
2207
|
{ provided_path: args.vex, size_bytes: vstat.size, limit_bytes: MAX_VEX_BYTES },
|
|
1876
2208
|
pretty
|
|
1877
2209
|
);
|
|
1878
2210
|
}
|
|
1879
2211
|
try {
|
|
1880
|
-
|
|
2212
|
+
// EE P1-2: BOM-tolerant read. Windows-tool-emitted CycloneDX commonly
|
|
2213
|
+
// carries UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
|
|
2214
|
+
// readFileSync chokes on the leading 0xFEFF.
|
|
2215
|
+
vexDoc = readJsonFile(args.vex);
|
|
1881
2216
|
} catch (e) {
|
|
1882
2217
|
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1883
2218
|
}
|
|
@@ -1899,6 +2234,16 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1899
2234
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
1900
2235
|
submission.signals = submission.signals || {};
|
|
1901
2236
|
submission.signals.vex_filter = [...vexSet];
|
|
2237
|
+
// BB P1-3: vexFilterFromDoc attaches a `.fixed` Set as an own property
|
|
2238
|
+
// on the returned filter Set (CycloneDX `analysis.state: 'resolved'`
|
|
2239
|
+
// + OpenVEX `status: 'fixed'` go into this set). Without forwarding it
|
|
2240
|
+
// through to signals.vex_fixed, analyze() never receives the fixed-
|
|
2241
|
+
// disposition CVE ids — `vexFixed` stays null, `vex_status: 'fixed'`
|
|
2242
|
+
// never gets annotated onto matched_cves entries, and CSAF
|
|
2243
|
+
// product_status.fixed + OpenVEX status:'fixed' are unreachable from
|
|
2244
|
+
// the CLI. The bundle-correctness tests only exercised the analyze()
|
|
2245
|
+
// direct-call path with vex_fixed pre-injected, hiding this regression.
|
|
2246
|
+
submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
|
|
1902
2247
|
} catch (e) {
|
|
1903
2248
|
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1904
2249
|
}
|
|
@@ -1935,9 +2280,27 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1935
2280
|
// v0.11.10 (#119): add result.ack alias for consumers reading the
|
|
1936
2281
|
// ack state by that name (`result.ack` is shorter + matches the CLI flag).
|
|
1937
2282
|
if (result && runOpts.operator) result.operator = runOpts.operator;
|
|
2283
|
+
|
|
2284
|
+
// EE P1-6: --ack consent only counts when a jurisdiction clock is actually
|
|
2285
|
+
// at stake — i.e. the run produced classification=detected (a real finding
|
|
2286
|
+
// that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
|
|
2287
|
+
// not-detected or inconclusive run, persisting the consent silently was
|
|
2288
|
+
// misleading: the attestation file recorded operator acknowledgement of
|
|
2289
|
+
// a clock that never started. Now: surface the ack state in the run body
|
|
2290
|
+
// either way so operators see what happened, but only persist
|
|
2291
|
+
// `operator_consent` into the attestation when classification === detected.
|
|
2292
|
+
const detectClassification = result && result.phases && result.phases.detect
|
|
2293
|
+
? result.phases.detect.classification
|
|
2294
|
+
: null;
|
|
2295
|
+
const consentApplies =
|
|
2296
|
+
!!runOpts.operator_consent && detectClassification === "detected";
|
|
1938
2297
|
if (result && runOpts.operator_consent) {
|
|
1939
2298
|
result.operator_consent = runOpts.operator_consent;
|
|
1940
2299
|
result.ack = !!runOpts.operator_consent.explicit;
|
|
2300
|
+
result.ack_applied = consentApplies;
|
|
2301
|
+
if (!consentApplies) {
|
|
2302
|
+
result.ack_skipped_reason = `classification=${detectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
2303
|
+
}
|
|
1941
2304
|
} else if (result) {
|
|
1942
2305
|
result.ack = false;
|
|
1943
2306
|
}
|
|
@@ -1950,17 +2313,25 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1950
2313
|
directiveId: result.directive_id,
|
|
1951
2314
|
evidenceHash: result.evidence_hash,
|
|
1952
2315
|
operator: runOpts.operator,
|
|
1953
|
-
|
|
2316
|
+
// EE P1-6: gate consent persistence on classification=detected.
|
|
2317
|
+
operatorConsent: consentApplies ? runOpts.operator_consent : null,
|
|
1954
2318
|
submission,
|
|
1955
2319
|
runOpts,
|
|
1956
2320
|
forceOverwrite: !!args["force-overwrite"],
|
|
1957
2321
|
filename: "attestation.json",
|
|
1958
2322
|
});
|
|
1959
2323
|
if (!persistResult.ok) {
|
|
1960
|
-
// Session-id collision without --force-overwrite
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
1963
|
-
// is a tamper-evidence
|
|
2324
|
+
// Session-id collision without --force-overwrite, OR --force-overwrite
|
|
2325
|
+
// lost the lockfile race. Refuse, surface the existing path so the
|
|
2326
|
+
// operator can decide, emit JSON to stderr matching the unified error
|
|
2327
|
+
// shape. Exit non-zero — a silent overwrite is a tamper-evidence
|
|
2328
|
+
// violation. v0.12.14: exitCode + return instead of process.exit so
|
|
2329
|
+
// the stderr line drains under piped CI consumers.
|
|
2330
|
+
//
|
|
2331
|
+
// When persistAttestation lost the lockfile race it pinned
|
|
2332
|
+
// process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
|
|
2333
|
+
// overwrite that with 3 — preserve the exit-8 contract callers depend
|
|
2334
|
+
// on to distinguish lock-busy from collision.
|
|
1964
2335
|
const err = {
|
|
1965
2336
|
ok: false,
|
|
1966
2337
|
error: persistResult.error,
|
|
@@ -1968,10 +2339,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1968
2339
|
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
1969
2340
|
verb: "run",
|
|
1970
2341
|
};
|
|
1971
|
-
|
|
1972
|
-
|
|
2342
|
+
if (persistResult.lock_contention) {
|
|
2343
|
+
err.lock_contention = true;
|
|
2344
|
+
err.exit_code = 8;
|
|
2345
|
+
}
|
|
1973
2346
|
process.stderr.write(JSON.stringify(err) + "\n");
|
|
1974
|
-
|
|
2347
|
+
if (!persistResult.lock_contention) {
|
|
2348
|
+
process.exitCode = 3;
|
|
2349
|
+
}
|
|
1975
2350
|
return;
|
|
1976
2351
|
}
|
|
1977
2352
|
if (persistResult.prior_session_id) {
|
|
@@ -2390,6 +2765,34 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2390
2765
|
if (!lst.isFile()) {
|
|
2391
2766
|
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
|
|
2392
2767
|
}
|
|
2768
|
+
// EE P1-5: Windows directory junctions are reparse-point dirs that
|
|
2769
|
+
// `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
|
|
2770
|
+
// ordinary directories). They bypass the symlink refusal above. Use
|
|
2771
|
+
// realpathSync to resolve the entry and confirm it still lives under
|
|
2772
|
+
// the resolved evidence-dir — the realpath approach is portable
|
|
2773
|
+
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
2774
|
+
// of whether the OS exposes reparse-point bits.
|
|
2775
|
+
let realEntry;
|
|
2776
|
+
try { realEntry = fs.realpathSync(entryPath); }
|
|
2777
|
+
catch (e) {
|
|
2778
|
+
return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
|
|
2779
|
+
}
|
|
2780
|
+
if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
|
|
2781
|
+
return emitError(
|
|
2782
|
+
`run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
|
|
2783
|
+
{ entry: f, resolved_to: realEntry },
|
|
2784
|
+
pretty
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
// EE P1-5 (hardlink defense in depth): no clean cross-platform refusal
|
|
2788
|
+
// exists — hardlinks are indistinguishable from regular files at the
|
|
2789
|
+
// inode level. Surface a stderr warning when nlink > 1 so the operator
|
|
2790
|
+
// is aware a second name may point at the same file. Not a refusal —
|
|
2791
|
+
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
2792
|
+
// nlink > 1 without malicious intent.
|
|
2793
|
+
if (lst.nlink > 1) {
|
|
2794
|
+
process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
|
|
2795
|
+
}
|
|
2393
2796
|
try {
|
|
2394
2797
|
bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
|
|
2395
2798
|
} catch (e) {
|
|
@@ -2412,6 +2815,32 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2412
2815
|
|
|
2413
2816
|
const result = runner.run(id, directiveId, submission, perRunOpts);
|
|
2414
2817
|
|
|
2818
|
+
// NN P1-4: mirror the cmdRun consent gate (EE P1-6). --ack consent only
|
|
2819
|
+
// counts when a jurisdiction clock is actually at stake on THIS
|
|
2820
|
+
// playbook's verdict — i.e. its detect.classification === 'detected'.
|
|
2821
|
+
// Pre-fix cmdRunMulti passed `perRunOpts.operator_consent` for every
|
|
2822
|
+
// playbook in the iteration regardless of that playbook's individual
|
|
2823
|
+
// classification, so a single --ack on a run-all invocation persisted
|
|
2824
|
+
// explicit consent into attestations whose run never started a clock.
|
|
2825
|
+
// Now: per-playbook gating with the same `ack_skipped_reason` surface
|
|
2826
|
+
// cmdRun emits, so consumers see exactly which playbooks consumed the
|
|
2827
|
+
// ack and which didn't.
|
|
2828
|
+
const perDetectClassification = result && result.phases && result.phases.detect
|
|
2829
|
+
? result.phases.detect.classification
|
|
2830
|
+
: null;
|
|
2831
|
+
const perConsentApplies =
|
|
2832
|
+
!!perRunOpts.operator_consent && perDetectClassification === "detected";
|
|
2833
|
+
if (result && perRunOpts.operator_consent) {
|
|
2834
|
+
result.operator_consent = perRunOpts.operator_consent;
|
|
2835
|
+
result.ack = !!perRunOpts.operator_consent.explicit;
|
|
2836
|
+
result.ack_applied = perConsentApplies;
|
|
2837
|
+
if (!perConsentApplies) {
|
|
2838
|
+
result.ack_skipped_reason = `classification=${perDetectClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
2839
|
+
}
|
|
2840
|
+
} else if (result) {
|
|
2841
|
+
result.ack = false;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2415
2844
|
// Persist per-playbook attestation under the shared session.
|
|
2416
2845
|
if (result && result.ok) {
|
|
2417
2846
|
const persisted = persistAttestation({
|
|
@@ -2420,7 +2849,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2420
2849
|
directiveId,
|
|
2421
2850
|
evidenceHash: result.evidence_hash,
|
|
2422
2851
|
operator: perRunOpts.operator,
|
|
2423
|
-
|
|
2852
|
+
// NN P1-4: gate consent persistence on this playbook's
|
|
2853
|
+
// classification, not on the aggregate run's --ack presence.
|
|
2854
|
+
operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
|
|
2424
2855
|
submission,
|
|
2425
2856
|
runOpts: perRunOpts,
|
|
2426
2857
|
forceOverwrite: !!args["force-overwrite"],
|
|
@@ -2486,7 +2917,11 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2486
2917
|
// strict `=== false` check failed and ingest silently treated the
|
|
2487
2918
|
// routing JSON as absent. CHANGELOG v0.12.16 F4 ("cmdIngest auto-
|
|
2488
2919
|
// detects piped stdin") was a no-op on Windows pre-fix.
|
|
2489
|
-
|
|
2920
|
+
//
|
|
2921
|
+
// EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
|
|
2922
|
+
// Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
|
|
2923
|
+
// would otherwise block here forever on the readFileSync(0) call.
|
|
2924
|
+
if (!args.evidence && hasReadableStdin()) {
|
|
2490
2925
|
args.evidence = "-";
|
|
2491
2926
|
}
|
|
2492
2927
|
if (args.evidence) {
|
|
@@ -2524,21 +2959,42 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2524
2959
|
// calls with the same session-id silently clobbered the audit trail and no
|
|
2525
2960
|
// .sig sidecar was written.
|
|
2526
2961
|
if (result && result.ok && result.session_id) {
|
|
2962
|
+
// Mirror cmdRun / cmdRunMulti: gate operator_consent persistence on
|
|
2963
|
+
// classification === 'detected'. --ack is meaningful only when a
|
|
2964
|
+
// jurisdiction clock is at stake; persisting consent on a
|
|
2965
|
+
// not-detected ingest forges audit-trail consent for a clock that
|
|
2966
|
+
// never started.
|
|
2967
|
+
const ingestClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
|
|
2968
|
+
const ingestConsentApplies = ingestClassification === "detected";
|
|
2969
|
+
if (runOpts.operator_consent && !ingestConsentApplies) {
|
|
2970
|
+
result.ack = true;
|
|
2971
|
+
result.ack_applied = false;
|
|
2972
|
+
result.ack_skipped_reason = `classification=${ingestClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
2973
|
+
}
|
|
2527
2974
|
const persisted = persistAttestation({
|
|
2528
2975
|
sessionId: result.session_id,
|
|
2529
2976
|
playbookId: result.playbook_id,
|
|
2530
2977
|
directiveId: result.directive_id,
|
|
2531
2978
|
evidenceHash: result.evidence_hash,
|
|
2532
2979
|
operator: runOpts.operator,
|
|
2533
|
-
operatorConsent: runOpts.operator_consent,
|
|
2980
|
+
operatorConsent: ingestConsentApplies ? runOpts.operator_consent : null,
|
|
2534
2981
|
submission: cleanedSubmission,
|
|
2535
2982
|
runOpts,
|
|
2536
2983
|
forceOverwrite: !!args["force-overwrite"],
|
|
2537
2984
|
filename: "attestation.json",
|
|
2538
2985
|
});
|
|
2539
2986
|
if (!persisted.ok) {
|
|
2540
|
-
// Surface the collision; do not silently clobber.
|
|
2541
|
-
|
|
2987
|
+
// Surface the collision; do not silently clobber. Preserve
|
|
2988
|
+
// LOCK_CONTENTION exit 8 set by persistAttestation when
|
|
2989
|
+
// --force-overwrite hit the lockfile race.
|
|
2990
|
+
const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
|
|
2991
|
+
if (persisted.lock_contention) {
|
|
2992
|
+
ctx.lock_contention = true;
|
|
2993
|
+
ctx.exit_code = 8;
|
|
2994
|
+
process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
return emitError(persisted.error, ctx, pretty);
|
|
2542
2998
|
}
|
|
2543
2999
|
if (persisted.prior_session_id) {
|
|
2544
3000
|
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
@@ -2691,8 +3147,17 @@ function persistAttestation(args) {
|
|
|
2691
3147
|
// matches withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling
|
|
2692
3148
|
// .lock file with bounded retry, PID-liveness check on contention,
|
|
2693
3149
|
// mtime fallback for orphaned lockfiles.
|
|
3150
|
+
// DD P1-2: MAX_RETRIES capped at 10 (was 50). persistAttestation is a
|
|
3151
|
+
// sync function called from sync callers throughout the CLI, so the
|
|
3152
|
+
// wait loop must busy-spin (no event-loop yield available). At 50
|
|
3153
|
+
// retries × ~200ms backoff per spin the worst case was ~10s of pegged-
|
|
3154
|
+
// CPU + frozen-event-loop stall under attestation contention. Capping
|
|
3155
|
+
// at 10 bounds the freeze at ~1-2s; beyond that callers receive the
|
|
3156
|
+
// LOCK_CONTENTION sentinel on the result object and can retry from the
|
|
3157
|
+
// outside without holding the CPU. Async refactor of persistAttestation
|
|
3158
|
+
// + every caller is a v0.13.0 candidate.
|
|
2694
3159
|
const lockPath = filePath + ".lock";
|
|
2695
|
-
const MAX_RETRIES =
|
|
3160
|
+
const MAX_RETRIES = 10;
|
|
2696
3161
|
const STALE_LOCK_MS = 30_000;
|
|
2697
3162
|
let acquired = false;
|
|
2698
3163
|
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
@@ -2729,10 +3194,28 @@ function persistAttestation(args) {
|
|
|
2729
3194
|
}
|
|
2730
3195
|
}
|
|
2731
3196
|
if (!acquired) {
|
|
3197
|
+
// DD P1-2: lock_contention sentinel so callers can distinguish a
|
|
3198
|
+
// genuine lock-busy condition (retry-from-outside is the right move)
|
|
3199
|
+
// from a hard failure (write error, permission denial). The sync
|
|
3200
|
+
// spin budget was bounded above so we hit this return after ~1-2s
|
|
3201
|
+
// of contention rather than the prior ~10s.
|
|
3202
|
+
//
|
|
3203
|
+
// PP P1-2: emit() auto-maps any ok:false body to process.exitCode = 1
|
|
3204
|
+
// (it only writes exitCode = 1 when the current value is 0). Pre-fix
|
|
3205
|
+
// the LOCK_CONTENTION return collapsed onto exit 1 along with every
|
|
3206
|
+
// other hard failure — defeating the "callers can distinguish
|
|
3207
|
+
// lock-busy from hard failure" promise. Pin process.exitCode = 8
|
|
3208
|
+
// HERE, before the caller hands the body to emit(); emit() will
|
|
3209
|
+
// preserve the already-non-zero value. Exit code 8 is reserved
|
|
3210
|
+
// exclusively for LOCK_CONTENTION (attestation persist); see the
|
|
3211
|
+
// exit-code table in printGlobalHelp().
|
|
3212
|
+
process.exitCode = 8;
|
|
2732
3213
|
return {
|
|
2733
3214
|
ok: false,
|
|
2734
|
-
error: `Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts.`,
|
|
3215
|
+
error: `LOCK_CONTENTION: Failed to acquire attestation lock at ${path.relative(process.cwd(), lockPath)} after ${MAX_RETRIES} attempts (~1-2s of contention). Retry the operation; if it persists, inspect the lockfile for a stale holder.`,
|
|
2735
3216
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3217
|
+
lock_contention: true,
|
|
3218
|
+
exit_code: 8,
|
|
2736
3219
|
};
|
|
2737
3220
|
}
|
|
2738
3221
|
try {
|
|
@@ -2770,7 +3253,7 @@ function persistAttestation(args) {
|
|
|
2770
3253
|
* from "the .sig file was deleted by an attacker."
|
|
2771
3254
|
*/
|
|
2772
3255
|
/**
|
|
2773
|
-
*
|
|
3256
|
+
* C: byte-stability normalize() for the attestation pipeline.
|
|
2774
3257
|
* Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
|
|
2775
3258
|
* normalize() implementations in lib/sign.js, lib/verify.js,
|
|
2776
3259
|
* lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
|
|
@@ -2796,7 +3279,7 @@ function maybeSignAttestation(filePath) {
|
|
|
2796
3279
|
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
2797
3280
|
// verifier doesn't trust.
|
|
2798
3281
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
2799
|
-
//
|
|
3282
|
+
// C: normalize attestation bytes before sign — strip leading
|
|
2800
3283
|
// UTF-8 BOM + collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
|
|
2801
3284
|
// lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
|
|
2802
3285
|
// attestation file lives on disk under .exceptd/ and can pick up CRLF
|
|
@@ -2826,20 +3309,25 @@ function maybeSignAttestation(filePath) {
|
|
|
2826
3309
|
key: privateKey,
|
|
2827
3310
|
dsaEncoding: "ieee-p1363",
|
|
2828
3311
|
});
|
|
3312
|
+
// KK P1-1: the sidecar's Ed25519 signature covers ONLY the
|
|
3313
|
+
// attestation file bytes. Fields that travel inside the .sig but are
|
|
3314
|
+
// NOT in the signed message are replay-rewrite trivial: an attacker
|
|
3315
|
+
// who can write the directory can mutate them without invalidating
|
|
3316
|
+
// the signature. Drop `signed_at`, `signs_path`, `signs_sha256` from
|
|
3317
|
+
// the sidecar shape — they were unsigned metadata posing as
|
|
3318
|
+
// attestation context. Operators reading freshness use filesystem
|
|
3319
|
+
// mtime; the attestation file's `captured_at` field is what's
|
|
3320
|
+
// signed. The sidecar now carries only the algorithm tag, the
|
|
3321
|
+
// Ed25519 signature payload, and an explanatory note.
|
|
2829
3322
|
fs.writeFileSync(sigPath, JSON.stringify({
|
|
2830
3323
|
algorithm: "Ed25519",
|
|
2831
3324
|
signature_base64: sig.toString("base64"),
|
|
2832
|
-
|
|
2833
|
-
signs_path: path.basename(filePath),
|
|
2834
|
-
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
3325
|
+
note: "Ed25519 signature covers the attestation file bytes only. Use filesystem mtime for freshness; use the attestation's `captured_at` for the signed timestamp.",
|
|
2835
3326
|
}, null, 2));
|
|
2836
3327
|
} else {
|
|
2837
3328
|
fs.writeFileSync(sigPath, JSON.stringify({
|
|
2838
3329
|
algorithm: "unsigned",
|
|
2839
3330
|
signed: false,
|
|
2840
|
-
signed_at: null,
|
|
2841
|
-
signs_path: path.basename(filePath),
|
|
2842
|
-
signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
|
|
2843
3331
|
note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
|
|
2844
3332
|
}, null, 2));
|
|
2845
3333
|
}
|
|
@@ -2852,7 +3340,7 @@ function maybeSignAttestation(filePath) {
|
|
|
2852
3340
|
* Returns null if neither has the session.
|
|
2853
3341
|
*/
|
|
2854
3342
|
/**
|
|
2855
|
-
* v0.12.14
|
|
3343
|
+
* v0.12.14: session-id validation — applied at every READ
|
|
2856
3344
|
* site, not just writes. The write path (persistAttestation) was hardened
|
|
2857
3345
|
* in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
|
|
2858
3346
|
* accepted arbitrary strings and joined them into path.join(root, id) with
|
|
@@ -2871,7 +3359,7 @@ function validateSessionIdForRead(sessionId) {
|
|
|
2871
3359
|
}
|
|
2872
3360
|
|
|
2873
3361
|
function findSessionDir(sessionId, runOpts) {
|
|
2874
|
-
// v0.12.14
|
|
3362
|
+
// v0.12.14: validate the session-id at every read path.
|
|
2875
3363
|
try { validateSessionIdForRead(sessionId); }
|
|
2876
3364
|
catch { return null; }
|
|
2877
3365
|
const candidates = [
|
|
@@ -2947,7 +3435,7 @@ function verifyAttestationSidecar(attFile) {
|
|
|
2947
3435
|
const sigPath = attFile + ".sig";
|
|
2948
3436
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
2949
3437
|
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
2950
|
-
//
|
|
3438
|
+
// Consult keys/EXPECTED_FINGERPRINT before honoring
|
|
2951
3439
|
// the loaded public key. The v0.12.16 CHANGELOG claimed "pin consulted
|
|
2952
3440
|
// at every public-key load site," but reattest's signature verifier
|
|
2953
3441
|
// loaded keys/public.pem without the pin cross-check. A coordinated
|
|
@@ -2965,17 +3453,66 @@ function verifyAttestationSidecar(attFile) {
|
|
|
2965
3453
|
}
|
|
2966
3454
|
let sigDoc;
|
|
2967
3455
|
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
2968
|
-
catch (e) {
|
|
3456
|
+
catch (e) {
|
|
3457
|
+
// a corrupt-JSON sidecar is observationally indistinguishable
|
|
3458
|
+
// from sidecar tamper — an attacker who can rewrite attestation.json can
|
|
3459
|
+
// also truncate / mangle the .sig file. Surface as a distinct
|
|
3460
|
+
// tamper-class reason so callers can require --force-replay. Pre-fix,
|
|
3461
|
+
// cmdReattest only refused on `reason === "no .sig sidecar"`; a
|
|
3462
|
+
// parse-error reason fell through to the benign NOTE branch and replay
|
|
3463
|
+
// proceeded against forged input.
|
|
3464
|
+
return {
|
|
3465
|
+
file: attFile,
|
|
3466
|
+
signed: false,
|
|
3467
|
+
verified: false,
|
|
3468
|
+
reason: `sidecar parse error: ${e.message}`,
|
|
3469
|
+
tamper_class: "sidecar-corrupt",
|
|
3470
|
+
};
|
|
3471
|
+
}
|
|
2969
3472
|
if (sigDoc.algorithm === "unsigned") {
|
|
3473
|
+
// `algorithm: "unsigned"` is only legitimate when written
|
|
3474
|
+
// by maybeSignAttestation() at attestation-creation time on a host
|
|
3475
|
+
// WITHOUT .keys/private.pem. If the verifying host HAS a private key,
|
|
3476
|
+
// an "unsigned" sidecar is a substitution attack: tamper attestation.json
|
|
3477
|
+
// (breaking Ed25519) then overwrite .sig with the unsigned stub to bypass
|
|
3478
|
+
// the tamper detector. Promote to tamper-class so callers can refuse.
|
|
3479
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3480
|
+
if (fs.existsSync(privKeyPath)) {
|
|
3481
|
+
return {
|
|
3482
|
+
file: attFile,
|
|
3483
|
+
signed: false,
|
|
3484
|
+
verified: false,
|
|
3485
|
+
reason: "attestation explicitly unsigned but .keys/private.pem IS present on this host — sidecar substitution suspected (legitimate unsigned attestations cannot exist alongside a private key)",
|
|
3486
|
+
tamper_class: "unsigned-substitution",
|
|
3487
|
+
};
|
|
3488
|
+
}
|
|
2970
3489
|
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
2971
3490
|
}
|
|
3491
|
+
// KK P1-3: strict algorithm check. Pre-fix the verifier branched only on
|
|
3492
|
+
// `=== "unsigned"`; null, undefined, "RSA-PSS", arrays, etc. fell through
|
|
3493
|
+
// to crypto.verify with the default Ed25519 args — which would either
|
|
3494
|
+
// succeed against the wrong-algorithm signature bytes accidentally (an
|
|
3495
|
+
// attacker who can write the sidecar can replay an existing Ed25519
|
|
3496
|
+
// signature under a downgrade-bait algorithm tag) or throw a generic
|
|
3497
|
+
// verify error. Refuse anything that isn't exactly "Ed25519" or
|
|
3498
|
+
// "unsigned" with a structured tamper class so callers can route the
|
|
3499
|
+
// refusal through the same exit-6 path as other tamper events.
|
|
3500
|
+
if (sigDoc.algorithm !== "Ed25519") {
|
|
3501
|
+
return {
|
|
3502
|
+
file: attFile,
|
|
3503
|
+
signed: false,
|
|
3504
|
+
verified: false,
|
|
3505
|
+
reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
|
|
3506
|
+
tamper_class: "algorithm-unsupported",
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
2972
3509
|
if (!pubKey) {
|
|
2973
3510
|
return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
2974
3511
|
}
|
|
2975
3512
|
let content;
|
|
2976
3513
|
try {
|
|
2977
3514
|
const raw = fs.readFileSync(attFile, "utf8");
|
|
2978
|
-
//
|
|
3515
|
+
// C: apply the same normalize() used by the signer so the
|
|
2979
3516
|
// verify path is byte-stable across CRLF / BOM churn (Windows checkout
|
|
2980
3517
|
// with core.autocrlf=true, editor round-trips, git-attributes flips).
|
|
2981
3518
|
content = normalizeAttestationBytes(raw);
|
|
@@ -3036,7 +3573,29 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3036
3573
|
// tampering. `verified === false && signed === true` is the real tamper
|
|
3037
3574
|
// signal.
|
|
3038
3575
|
const verify = verifyAttestationSidecar(attFile);
|
|
3039
|
-
|
|
3576
|
+
// 2: collapse tamper-class detection. Any non-benign
|
|
3577
|
+
// sidecar state (signed-but-invalid, sidecar-corrupt, unsigned-substitution)
|
|
3578
|
+
// refuses replay unless --force-replay is set. The pre-fix shape only
|
|
3579
|
+
// refused on `verify.signed && !verify.verified` (signed-tamper) and on
|
|
3580
|
+
// `no .sig sidecar` (missing); corrupt-JSON sidecars and substituted
|
|
3581
|
+
// "unsigned" sidecars on a host WITH a private key fell into the benign
|
|
3582
|
+
// NOTE branch and replay proceeded against forged input.
|
|
3583
|
+
const isSignedTamper = verify.signed && !verify.verified;
|
|
3584
|
+
const isClassTamper = !verify.signed && (
|
|
3585
|
+
verify.tamper_class === "sidecar-corrupt"
|
|
3586
|
+
|| verify.tamper_class === "unsigned-substitution"
|
|
3587
|
+
// KK P1-3: extend tamper-class refusal to algorithm-unsupported sidecars
|
|
3588
|
+
// (anything other than "Ed25519" or "unsigned"). Pre-fix, the verifier
|
|
3589
|
+
// pre-strict-check would crypto.verify against default Ed25519 args and
|
|
3590
|
+
// return signed:true + verified:false on failure — which DID land in
|
|
3591
|
+
// isSignedTamper. But a sidecar that throws inside crypto.verify (e.g.
|
|
3592
|
+
// signature_base64 missing on the downgrade-bait shape) was routed
|
|
3593
|
+
// through the catch block and emerged as signed:true + verified:false
|
|
3594
|
+
// by happy accident. The strict pre-check now surfaces the class
|
|
3595
|
+
// directly; refuse on that class too.
|
|
3596
|
+
|| verify.tamper_class === "algorithm-unsupported"
|
|
3597
|
+
);
|
|
3598
|
+
if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
|
|
3040
3599
|
process.stderr.write(`[exceptd reattest] TAMPERED: attestation at ${attFile} failed Ed25519 verification (${verify.reason}). Refusing to replay against forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
|
|
3041
3600
|
const body = {
|
|
3042
3601
|
ok: false,
|
|
@@ -3051,10 +3610,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3051
3610
|
process.exitCode = 6;
|
|
3052
3611
|
return;
|
|
3053
3612
|
}
|
|
3054
|
-
if (
|
|
3613
|
+
if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
|
|
3055
3614
|
process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding failed signature verification on ${attFile} (${verify.reason}). The replay output records sidecar_verify so the override is audit-visible.\n`);
|
|
3056
3615
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && !args["force-replay"]) {
|
|
3057
|
-
//
|
|
3616
|
+
// missing-sidecar is NOT benign. The previous flow accepted
|
|
3058
3617
|
// a missing .sig file silently (only blocked on signed-but-invalid).
|
|
3059
3618
|
// Sidecar deletion is observationally identical to sidecar tamper —
|
|
3060
3619
|
// an attacker who can rewrite the attestation can also rm the sidecar,
|
|
@@ -3079,6 +3638,30 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3079
3638
|
return;
|
|
3080
3639
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
|
|
3081
3640
|
process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding missing .sig sidecar on ${attFile}. The replay output records sidecar_verify so the override is audit-visible.\n`);
|
|
3641
|
+
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && !args["force-replay"]) {
|
|
3642
|
+
// legitimately-unsigned attestations (written when the
|
|
3643
|
+
// attesting host had no private key) require --force-replay to consume.
|
|
3644
|
+
// Pre-fix, the NOTE branch accepted them silently — which let an
|
|
3645
|
+
// attacker swap a valid .sig with the unsigned stub on a host that
|
|
3646
|
+
// happens to be private-key-absent at verify time. The cost of
|
|
3647
|
+
// requiring --force-replay is one explicit operator step; the benefit
|
|
3648
|
+
// is that any unsigned-substitution event becomes audit-visible via
|
|
3649
|
+
// sidecar_verify + force_replay in the emitted body.
|
|
3650
|
+
process.stderr.write(`[exceptd reattest] EXPLICITLY-UNSIGNED: attestation at ${attFile} carries an "unsigned" sidecar (${verify.reason}). Replay against unsigned input requires --force-replay so the audit trail captures the override.\n`);
|
|
3651
|
+
const body = {
|
|
3652
|
+
ok: false,
|
|
3653
|
+
error: `reattest: prior attestation is explicitly unsigned — refusing to replay without --force-replay`,
|
|
3654
|
+
verb: "reattest",
|
|
3655
|
+
session_id: sessionId,
|
|
3656
|
+
attestation_file: attFile,
|
|
3657
|
+
sidecar_verify: verify,
|
|
3658
|
+
hint: "If the original attestation was legitimately produced without a private key, pass --force-replay. The replay body will record sidecar_verify: 'explicitly-unsigned' + force_replay: true.",
|
|
3659
|
+
};
|
|
3660
|
+
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3661
|
+
process.exitCode = 6;
|
|
3662
|
+
return;
|
|
3663
|
+
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
|
|
3664
|
+
process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding explicitly-unsigned attestation on ${attFile}. The replay output records sidecar_verify: 'explicitly-unsigned' so the override is audit-visible.\n`);
|
|
3082
3665
|
} else if (!verify.signed && verify.reason !== "no .sig sidecar") {
|
|
3083
3666
|
process.stderr.write(`[exceptd reattest] NOTE: attestation at ${attFile} has no Ed25519 signature (${verify.reason}). Proceeding — unsigned attestations are an operator config issue, not tamper evidence.\n`);
|
|
3084
3667
|
}
|
|
@@ -3135,6 +3718,57 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3135
3718
|
}
|
|
3136
3719
|
}
|
|
3137
3720
|
|
|
3721
|
+
const replayedAt = new Date().toISOString();
|
|
3722
|
+
const sidecarVerifyClass = classifySidecarVerify(verify);
|
|
3723
|
+
const forceReplay = !!args["force-replay"];
|
|
3724
|
+
|
|
3725
|
+
// KK P1-2: persist a `replay-<isoZ>.json` audit record under the session
|
|
3726
|
+
// directory whenever cmdReattest produced a replay verdict. Pre-fix the
|
|
3727
|
+
// force-replay branches emitted the override body to stdout but never
|
|
3728
|
+
// wrote it to disk; once the operator's shell closed the override was
|
|
3729
|
+
// invisible to any subsequent auditor. Now every replay writes a new
|
|
3730
|
+
// file alongside the original attestation.json, signed via the standard
|
|
3731
|
+
// maybeSignAttestation path so the audit chain remains tamper-evident.
|
|
3732
|
+
// The file is picked up automatically by `attest verify <sid>` (which
|
|
3733
|
+
// already iterates every *.json under the session dir).
|
|
3734
|
+
//
|
|
3735
|
+
// Filename shape: ISO-8601 uses ':' which the persistAttestation regex
|
|
3736
|
+
// refuses; substitute ':' with '-' and keep millisecond precision so
|
|
3737
|
+
// multiple replays in the same second do not collide on EEXIST. The
|
|
3738
|
+
// resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
|
|
3739
|
+
const replayFilename = "replay-" + replayedAt.replace(/:/g, "-") + ".json";
|
|
3740
|
+
let replayPersisted = null;
|
|
3741
|
+
try {
|
|
3742
|
+
const replayBody = {
|
|
3743
|
+
kind: "replay",
|
|
3744
|
+
session_id: sessionId,
|
|
3745
|
+
playbook_id: prior.playbook_id,
|
|
3746
|
+
directive_id: prior.directive_id,
|
|
3747
|
+
status,
|
|
3748
|
+
prior_evidence_hash: priorHash,
|
|
3749
|
+
replay_evidence_hash: newHash,
|
|
3750
|
+
prior_captured_at: prior.captured_at,
|
|
3751
|
+
replayed_at: replayedAt,
|
|
3752
|
+
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
3753
|
+
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
3754
|
+
sidecar_verify: verify,
|
|
3755
|
+
sidecar_verify_class: sidecarVerifyClass,
|
|
3756
|
+
force_replay: forceReplay,
|
|
3757
|
+
};
|
|
3758
|
+
const replayPath = path.join(path.dirname(attFile), replayFilename);
|
|
3759
|
+
// O_EXCL 'wx' — millisecond-level filename + EEXIST refusal so two
|
|
3760
|
+
// concurrent reattests do not silently overwrite each other.
|
|
3761
|
+
fs.writeFileSync(replayPath, JSON.stringify(replayBody, null, 2), { flag: "wx" });
|
|
3762
|
+
maybeSignAttestation(replayPath);
|
|
3763
|
+
replayPersisted = { ok: true, path: replayPath };
|
|
3764
|
+
} catch (e) {
|
|
3765
|
+
// Non-fatal — the stdout emit is the operator's primary surface; a
|
|
3766
|
+
// disk-persistence failure shouldn't mask the verdict. Surface the
|
|
3767
|
+
// condition in the response body so an operator-side audit pipeline
|
|
3768
|
+
// can re-run the persist later.
|
|
3769
|
+
replayPersisted = { ok: false, error: String((e && e.message) || e) };
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3138
3772
|
emit({
|
|
3139
3773
|
ok: true,
|
|
3140
3774
|
verb: "reattest",
|
|
@@ -3145,16 +3779,55 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3145
3779
|
prior_evidence_hash: priorHash,
|
|
3146
3780
|
replay_evidence_hash: newHash,
|
|
3147
3781
|
prior_captured_at: prior.captured_at,
|
|
3148
|
-
replayed_at:
|
|
3782
|
+
replayed_at: replayedAt,
|
|
3149
3783
|
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
3150
3784
|
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
3151
3785
|
// F10: persist the sidecar verify result + the force-replay flag so the
|
|
3152
3786
|
// audit trail records whether the replay was authenticated input.
|
|
3153
3787
|
sidecar_verify: verify,
|
|
3154
|
-
|
|
3788
|
+
// emit a one-token classification label alongside the
|
|
3789
|
+
// full sidecar_verify object so log scrapers / dashboards can filter on
|
|
3790
|
+
// override events without parsing reason strings. Values:
|
|
3791
|
+
// 'verified' — Ed25519 sidecar verified
|
|
3792
|
+
// 'tampered' — signed-but-invalid signature (post-hoc tamper)
|
|
3793
|
+
// 'sidecar-corrupt' — sidecar JSON parse failure (tamper class)
|
|
3794
|
+
// 'unsigned-substitution'— "unsigned" sidecar on a host with private key
|
|
3795
|
+
// (substitution attack signal)
|
|
3796
|
+
// 'algorithm-unsupported'— sidecar algorithm field is neither "Ed25519"
|
|
3797
|
+
// nor "unsigned" (downgrade-bait substitution)
|
|
3798
|
+
// 'explicitly-unsigned' — legitimately-unsigned attestation
|
|
3799
|
+
// 'no-sidecar' — sidecar file absent
|
|
3800
|
+
// 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
|
|
3801
|
+
sidecar_verify_class: sidecarVerifyClass,
|
|
3802
|
+
force_replay: forceReplay,
|
|
3803
|
+
// KK P1-2: surface the persisted replay-record path (or persistence
|
|
3804
|
+
// failure reason) so an auditor reading the CLI response can locate the
|
|
3805
|
+
// on-disk artifact without re-deriving the filename.
|
|
3806
|
+
replay_persisted: replayPersisted,
|
|
3155
3807
|
}, pretty);
|
|
3156
3808
|
}
|
|
3157
3809
|
|
|
3810
|
+
/**
|
|
3811
|
+
* map a verifyAttestationSidecar() result to a one-token
|
|
3812
|
+
* classification label. The label is persisted alongside the full
|
|
3813
|
+
* sidecar_verify object so auditors can filter override events by class
|
|
3814
|
+
* without regexing the human-readable reason string.
|
|
3815
|
+
*/
|
|
3816
|
+
function classifySidecarVerify(verify) {
|
|
3817
|
+
if (!verify || typeof verify !== "object") return "unknown";
|
|
3818
|
+
if (verify.signed && verify.verified) return "verified";
|
|
3819
|
+
if (verify.signed && !verify.verified) return "tampered";
|
|
3820
|
+
if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
|
|
3821
|
+
if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
|
|
3822
|
+
// KK P1-3: algorithm-unsupported is its own class label so log scrapers /
|
|
3823
|
+
// dashboards can filter downgrade-bait events without parsing the reason.
|
|
3824
|
+
if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
|
|
3825
|
+
if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
|
|
3826
|
+
if (typeof verify.reason === "string" && verify.reason.includes("no .sig sidecar")) return "no-sidecar";
|
|
3827
|
+
if (typeof verify.reason === "string" && verify.reason.includes("no public key")) return "no-public-key";
|
|
3828
|
+
return "unknown";
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3158
3831
|
/**
|
|
3159
3832
|
* `exceptd attest <subverb> <session-id>` — auditor-facing operations on
|
|
3160
3833
|
* persisted attestations. Subverbs:
|
|
@@ -3261,7 +3934,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3261
3934
|
const crypto = require("crypto");
|
|
3262
3935
|
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
3263
3936
|
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
3264
|
-
//
|
|
3937
|
+
// Same pin cross-check as verifyAttestationSidecar().
|
|
3265
3938
|
// The v0.12.16 promise that EXPECTED_FINGERPRINT is consulted at every
|
|
3266
3939
|
// public-key load site was not honored here — `attest verify` loaded
|
|
3267
3940
|
// keys/public.pem raw. Refuse to verify any sidecar when the local
|
|
@@ -3274,13 +3947,62 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3274
3947
|
pretty
|
|
3275
3948
|
);
|
|
3276
3949
|
}
|
|
3950
|
+
// on the verifying host, detect "unsigned" sidecar
|
|
3951
|
+
// substitution by checking whether .keys/private.pem is present. A
|
|
3952
|
+
// legitimately-unsigned attestation cannot coexist with a private key on
|
|
3953
|
+
// the same host — that combination is sidecar substitution (attacker
|
|
3954
|
+
// tampered attestation.json and overwrote .sig with the unsigned stub).
|
|
3955
|
+
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3956
|
+
const hasPrivKey = fs.existsSync(privKeyPath);
|
|
3277
3957
|
const results = files.map(f => {
|
|
3278
3958
|
const sigPath = path.join(dir, f + ".sig");
|
|
3279
3959
|
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
3280
|
-
|
|
3281
|
-
|
|
3960
|
+
// wrap JSON.parse so a corrupt sidecar surfaces as a
|
|
3961
|
+
// structured tamper-class result (signed:false, verified:false,
|
|
3962
|
+
// tamper_class:"sidecar-corrupt") rather than throwing into the outer
|
|
3963
|
+
// dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
|
|
3964
|
+
// generic exit-1 with no `results` array — operators piping through
|
|
3965
|
+
// `set -e` saw "command failed" with no tamper signal.
|
|
3966
|
+
let sigDoc;
|
|
3967
|
+
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
3968
|
+
catch (e) {
|
|
3969
|
+
return {
|
|
3970
|
+
file: f,
|
|
3971
|
+
signed: false,
|
|
3972
|
+
verified: false,
|
|
3973
|
+
reason: `sidecar parse error: ${e.message}`,
|
|
3974
|
+
tamper_class: "sidecar-corrupt",
|
|
3975
|
+
};
|
|
3976
|
+
}
|
|
3977
|
+
if (sigDoc.algorithm === "unsigned") {
|
|
3978
|
+
// substitution detection.
|
|
3979
|
+
if (hasPrivKey) {
|
|
3980
|
+
return {
|
|
3981
|
+
file: f,
|
|
3982
|
+
signed: false,
|
|
3983
|
+
verified: false,
|
|
3984
|
+
reason: "attestation explicitly unsigned but .keys/private.pem IS present on this host — sidecar substitution suspected (legitimate unsigned attestations cannot exist alongside a private key)",
|
|
3985
|
+
tamper_class: "unsigned-substitution",
|
|
3986
|
+
};
|
|
3987
|
+
}
|
|
3988
|
+
return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3989
|
+
}
|
|
3990
|
+
// KK P1-3: strict algorithm check (mirrors verifyAttestationSidecar).
|
|
3991
|
+
// Anything that isn't exactly "Ed25519" or "unsigned" is refused as
|
|
3992
|
+
// tamper-class. Pre-fix null / "RSA-PSS" / arrays fell through to
|
|
3993
|
+
// crypto.verify with Ed25519 defaults, producing either an opaque
|
|
3994
|
+
// verify-throw or a downgrade-bait acceptance path.
|
|
3995
|
+
if (sigDoc.algorithm !== "Ed25519") {
|
|
3996
|
+
return {
|
|
3997
|
+
file: f,
|
|
3998
|
+
signed: false,
|
|
3999
|
+
verified: false,
|
|
4000
|
+
reason: "unsupported algorithm: " + JSON.stringify(sigDoc.algorithm),
|
|
4001
|
+
tamper_class: "algorithm-unsupported",
|
|
4002
|
+
};
|
|
4003
|
+
}
|
|
3282
4004
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
3283
|
-
//
|
|
4005
|
+
// C: normalize before crypto.verify — mirrors the signer
|
|
3284
4006
|
// path so the verify pair is byte-stable across CRLF / BOM churn.
|
|
3285
4007
|
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
3286
4008
|
const content = normalizeAttestationBytes(rawContent);
|
|
@@ -3300,7 +4022,20 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3300
4022
|
// signal even when an attestation had been forged. emit()'s ok:false
|
|
3301
4023
|
// → exitCode = 1 auto-promotion would stop at 1; tamper is distinct
|
|
3302
4024
|
// from generic failure, so explicitly raise to 6 (cmdReattest's code).
|
|
3303
|
-
|
|
4025
|
+
//
|
|
4026
|
+
// 2: extend the tamper predicate to cover the new
|
|
4027
|
+
// tamper_class variants. Pre-fix the predicate was `r.signed && !r.verified`
|
|
4028
|
+
// which missed (a) corrupt-JSON sidecars (signed:false) and (b) "unsigned"
|
|
4029
|
+
// sidecar substitution on hosts with a private key (signed:false). Both
|
|
4030
|
+
// are tamper-class events and must promote to exit 6.
|
|
4031
|
+
const tampered = results.some(r =>
|
|
4032
|
+
(r.signed && !r.verified)
|
|
4033
|
+
|| r.tamper_class === "sidecar-corrupt"
|
|
4034
|
+
|| r.tamper_class === "unsigned-substitution"
|
|
4035
|
+
// KK P1-3: a sidecar whose algorithm field is not "Ed25519" or
|
|
4036
|
+
// "unsigned" is a downgrade-bait substitution; promote to exit 6.
|
|
4037
|
+
|| r.tamper_class === "algorithm-unsupported"
|
|
4038
|
+
);
|
|
3304
4039
|
const body = { verb: "attest verify", session_id: sessionId, results };
|
|
3305
4040
|
if (tampered) {
|
|
3306
4041
|
body.ok = false;
|
|
@@ -4088,7 +4823,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4088
4823
|
}
|
|
4089
4824
|
|
|
4090
4825
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
4091
|
-
// v0.12.14
|
|
4826
|
+
// v0.12.14: --playbook is registered as `multi:` so
|
|
4092
4827
|
// `--playbook a --playbook b` lands as an array. The prior filter used
|
|
4093
4828
|
// strict equality (`j.playbook_id !== args.playbook`) — always false for
|
|
4094
4829
|
// array, silently producing count: 0. Normalize to a Set up-front.
|
|
@@ -4097,7 +4832,7 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4097
4832
|
const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
|
|
4098
4833
|
return new Set(list.filter(x => typeof x === "string" && x.length > 0));
|
|
4099
4834
|
})();
|
|
4100
|
-
// v0.12.14
|
|
4835
|
+
// v0.12.14: --since must be a parseable ISO-8601 timestamp.
|
|
4101
4836
|
// Prior behavior silently accepted any string and lexically compared to
|
|
4102
4837
|
// captured_at, producing 0-result or full-result depending on the string.
|
|
4103
4838
|
if (args.since != null) {
|
|
@@ -4248,7 +4983,10 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4248
4983
|
if (args.evidence) {
|
|
4249
4984
|
try { payload = readEvidence(args.evidence); }
|
|
4250
4985
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
4251
|
-
} else if (
|
|
4986
|
+
} else if (hasReadableStdin()) {
|
|
4987
|
+
// EE P1-7: hasReadableStdin() probes via fstat before falling into
|
|
4988
|
+
// readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
|
|
4989
|
+
// size===0) would otherwise hang here.
|
|
4252
4990
|
// Drain stdin for any evidence event.
|
|
4253
4991
|
try {
|
|
4254
4992
|
const buf = fs.readFileSync(0, "utf8");
|
|
@@ -4289,19 +5027,27 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4289
5027
|
process.exitCode = 1;
|
|
4290
5028
|
return;
|
|
4291
5029
|
}
|
|
4292
|
-
// v0.12.14
|
|
5030
|
+
// v0.12.14: ai-run --no-stream previously emitted a
|
|
4293
5031
|
// session_id but never persisted the attestation, so the AI agent
|
|
4294
5032
|
// calling ai-run couldn't chain into `attest show / verify / diff`
|
|
4295
5033
|
// or `reattest` with the returned id. Now: same persistAttestation
|
|
4296
5034
|
// shape as cmdRun, so AI-facing flow round-trips cleanly.
|
|
4297
5035
|
if (result.session_id) {
|
|
5036
|
+
// Mirror cmdRun: gate operator_consent on classification === 'detected'.
|
|
5037
|
+
const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
|
|
5038
|
+
const aiConsentApplies = aiClassification === "detected";
|
|
5039
|
+
if (runOpts.operator_consent && !aiConsentApplies) {
|
|
5040
|
+
result.ack = true;
|
|
5041
|
+
result.ack_applied = false;
|
|
5042
|
+
result.ack_skipped_reason = `classification=${aiClassification || "unknown"}; consent only persisted when classification=detected (jurisdiction clock at stake).`;
|
|
5043
|
+
}
|
|
4298
5044
|
const persistResult = persistAttestation({
|
|
4299
5045
|
sessionId: result.session_id,
|
|
4300
5046
|
playbookId: result.playbook_id || playbookId,
|
|
4301
5047
|
directiveId: result.directive_id || directiveId,
|
|
4302
5048
|
evidenceHash: result.evidence_hash,
|
|
4303
5049
|
operator: runOpts.operator,
|
|
4304
|
-
operatorConsent: runOpts.operator_consent,
|
|
5050
|
+
operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
|
|
4305
5051
|
submission,
|
|
4306
5052
|
runOpts,
|
|
4307
5053
|
forceOverwrite: !!args["force-overwrite"],
|
|
@@ -4310,12 +5056,21 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4310
5056
|
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
4311
5057
|
// Collision without --force-overwrite. AI agents typically pass
|
|
4312
5058
|
// unique session ids each run, so this path is rare but surface
|
|
4313
|
-
// it cleanly via the same JSONL contract.
|
|
4314
|
-
|
|
5059
|
+
// it cleanly via the same JSONL contract. Preserve LOCK_CONTENTION
|
|
5060
|
+
// exit 8 set by persistAttestation when --force-overwrite hit the
|
|
5061
|
+
// lockfile race — don't clobber with exit 3.
|
|
5062
|
+
const eventBody = {
|
|
4315
5063
|
event: "error", reason: persistResult.error,
|
|
4316
5064
|
existing_attestation: persistResult.existingPath,
|
|
4317
|
-
}
|
|
4318
|
-
|
|
5065
|
+
};
|
|
5066
|
+
if (persistResult.lock_contention) {
|
|
5067
|
+
eventBody.lock_contention = true;
|
|
5068
|
+
eventBody.exit_code = 8;
|
|
5069
|
+
}
|
|
5070
|
+
process.stdout.write(JSON.stringify(eventBody) + "\n");
|
|
5071
|
+
if (!persistResult.lock_contention) {
|
|
5072
|
+
process.exitCode = 3;
|
|
5073
|
+
}
|
|
4319
5074
|
return;
|
|
4320
5075
|
}
|
|
4321
5076
|
}
|
|
@@ -4395,25 +5150,35 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4395
5150
|
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
4396
5151
|
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
4397
5152
|
writeLine({ phase: "close", ...result.phases?.close });
|
|
4398
|
-
// v0.12.14
|
|
5153
|
+
// v0.12.14: persist the attestation in streaming mode
|
|
4399
5154
|
// too. Without this, the session_id emitted in the `done` frame
|
|
4400
5155
|
// can't be resolved by `attest show / verify / diff` or `reattest`.
|
|
4401
5156
|
if (result.session_id) {
|
|
5157
|
+
// Mirror cmdRun: gate operator_consent on classification === 'detected'.
|
|
5158
|
+
const aiClassification = result.phases && result.phases.detect ? result.phases.detect.classification : null;
|
|
5159
|
+
const aiConsentApplies = aiClassification === "detected";
|
|
4402
5160
|
const persistResult = persistAttestation({
|
|
4403
5161
|
sessionId: result.session_id,
|
|
4404
5162
|
playbookId: result.playbook_id || playbookId,
|
|
4405
5163
|
directiveId: result.directive_id || directiveId,
|
|
4406
5164
|
evidenceHash: result.evidence_hash,
|
|
4407
5165
|
operator: runOpts.operator,
|
|
4408
|
-
operatorConsent: runOpts.operator_consent,
|
|
5166
|
+
operatorConsent: aiConsentApplies ? runOpts.operator_consent : null,
|
|
4409
5167
|
submission,
|
|
4410
5168
|
runOpts,
|
|
4411
5169
|
forceOverwrite: !!args["force-overwrite"],
|
|
4412
5170
|
filename: "attestation.json",
|
|
4413
5171
|
});
|
|
4414
5172
|
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
4415
|
-
|
|
4416
|
-
|
|
5173
|
+
const eventBody = { event: "error", reason: persistResult.error,
|
|
5174
|
+
existing_attestation: persistResult.existingPath };
|
|
5175
|
+
if (persistResult.lock_contention) {
|
|
5176
|
+
eventBody.lock_contention = true;
|
|
5177
|
+
eventBody.exit_code = 8;
|
|
5178
|
+
writeLine(eventBody);
|
|
5179
|
+
return finish(8);
|
|
5180
|
+
}
|
|
5181
|
+
writeLine(eventBody);
|
|
4417
5182
|
return finish(3);
|
|
4418
5183
|
}
|
|
4419
5184
|
}
|