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