@blamejs/exceptd-skills 0.12.22 → 0.12.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -12
- package/ARCHITECTURE.md +2 -2
- package/CHANGELOG.md +152 -2
- package/CONTEXT.md +126 -69
- package/README.md +21 -8
- package/bin/exceptd.js +972 -464
- package/data/_indexes/_meta.json +3 -3
- package/data/_indexes/stale-content.json +10 -3
- package/data/playbooks/ai-api.json +1 -1
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/cred-stores.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/kernel.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +1 -1
- package/data/playbooks/sbom.json +1 -1
- package/data/playbooks/secrets.json +39 -1
- package/lib/auto-discovery.js +28 -4
- package/lib/cross-ref-api.js +12 -11
- package/lib/cve-curation.js +18 -19
- package/lib/exit-codes.js +72 -0
- package/lib/flag-suggest.js +130 -0
- package/lib/id-validation.js +95 -0
- package/lib/lint-skills.js +73 -6
- package/lib/playbook-runner.js +617 -343
- package/lib/prefetch.js +134 -21
- package/lib/refresh-external.js +205 -26
- package/lib/refresh-network.js +64 -16
- package/lib/schemas/cve-catalog.schema.json +7 -1
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +49 -7
- package/lib/sign.js +10 -11
- package/lib/source-osv.js +7 -7
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/validate-catalog-meta.js +1 -1
- package/lib/validate-cve-catalog.js +1 -1
- package/lib/verify.js +56 -30
- package/manifest.json +40 -40
- package/package.json +8 -2
- package/sbom.cdx.json +6 -6
- package/scripts/check-test-coverage.js +67 -0
- package/scripts/verify-shipped-tarball.js +27 -18
package/bin/exceptd.js
CHANGED
|
@@ -58,6 +58,15 @@ const { spawnSync } = require("child_process");
|
|
|
58
58
|
// (e.g. <somewhere>/node_modules/@blamejs/exceptd-skills).
|
|
59
59
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
60
60
|
|
|
61
|
+
// Centralised exit-code constants + id validators + flag-typo suggester.
|
|
62
|
+
// Replacing the prior bare-numbers + inline-regex pattern with named
|
|
63
|
+
// constants so a new verb cannot regress the exit-code contract by typo,
|
|
64
|
+
// and so the help-text dump (`doctor --exit-codes`) and the runtime
|
|
65
|
+
// behavior share the same source of truth.
|
|
66
|
+
const { EXIT_CODES, listExitCodes } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
|
|
67
|
+
const { validateIdComponent } = require(path.join(PKG_ROOT, "lib", "id-validation.js"));
|
|
68
|
+
const { suggestFlag, flagsFor } = require(path.join(PKG_ROOT, "lib", "flag-suggest.js"));
|
|
69
|
+
|
|
61
70
|
/**
|
|
62
71
|
* Factor the EXPECTED_FINGERPRINT pin check used by
|
|
63
72
|
* the attestation pipeline. Centralizes the policy (compute live SHA-256
|
|
@@ -85,14 +94,21 @@ function assertExpectedFingerprint(pubKeyPem) {
|
|
|
85
94
|
} catch (e) {
|
|
86
95
|
return `EXPECTED_FINGERPRINT check: failed to derive live fingerprint: ${e.message}`;
|
|
87
96
|
}
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
97
|
+
// Route through the shared lib/verify loader so a BOM-prefixed pin file
|
|
98
|
+
// (Notepad with files.encoding=utf8bom) is tolerated identically across
|
|
99
|
+
// every verify site. The helper strips leading U+FEFF + ignores comment
|
|
100
|
+
// lines.
|
|
92
101
|
const { loadExpectedFingerprintFirstLine } = require(path.join(PKG_ROOT, "lib", "verify.js"));
|
|
93
102
|
const firstLine = loadExpectedFingerprintFirstLine(pinPath) || "";
|
|
94
103
|
if (firstLine === liveFp) return null;
|
|
95
|
-
if (process.env.KEYS_ROTATED === "1")
|
|
104
|
+
if (process.env.KEYS_ROTATED === "1") {
|
|
105
|
+
process.emitWarning(
|
|
106
|
+
`EXPECTED_FINGERPRINT mismatch accepted via KEYS_ROTATED=1: live=${liveFp} pin=${firstLine}. ` +
|
|
107
|
+
`Update keys/EXPECTED_FINGERPRINT to lock the new pin.`,
|
|
108
|
+
{ code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
|
|
109
|
+
);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
96
112
|
return (
|
|
97
113
|
`EXPECTED_FINGERPRINT mismatch: live=${liveFp} pin=${firstLine}. ` +
|
|
98
114
|
`If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
|
|
@@ -268,9 +284,11 @@ v0.12.0 canonical surface
|
|
|
268
284
|
--registry-check (v0.11.14) opt-in: query npm registry
|
|
269
285
|
for latest published version + days behind
|
|
270
286
|
|
|
271
|
-
ci One-shot CI gate. Exit codes: 0 PASS,
|
|
272
|
-
3 ran-but-no-evidence,
|
|
273
|
-
5 jurisdiction clock started
|
|
287
|
+
ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
|
|
288
|
+
2 detected/escalate, 3 ran-but-no-evidence,
|
|
289
|
+
4 blocked (ok:false), 5 jurisdiction clock started.
|
|
290
|
+
(Codes 6/7/8/9 surface on attest verify / run /
|
|
291
|
+
ai-run / ingest, not ci.)
|
|
274
292
|
--all | --scope <type> | (auto-detect)
|
|
275
293
|
--max-rwep <n> cap below playbook default
|
|
276
294
|
--block-on-jurisdiction-clock
|
|
@@ -346,6 +364,8 @@ Examples:
|
|
|
346
364
|
exceptd ci --scope code --max-rwep 70 # gate every code playbook
|
|
347
365
|
exceptd ask "I think someone replaced npm packages" # natural-language route
|
|
348
366
|
|
|
367
|
+
Unknown verbs exit 2 with a structured ok:false body on stderr.
|
|
368
|
+
|
|
349
369
|
Full documentation: ${PKG_ROOT}/README.md
|
|
350
370
|
Project rules: ${PKG_ROOT}/AGENTS.md
|
|
351
371
|
`);
|
|
@@ -495,11 +515,9 @@ function main() {
|
|
|
495
515
|
if (typeof resolver !== "function") {
|
|
496
516
|
// Emit a structured JSON error matching the seven-phase verbs so operators
|
|
497
517
|
// piping through `jq` get one consistent shape across the CLI surface.
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
// exitCode + return per v0.12.14) so the JSON drains, then promote
|
|
502
|
-
// the exit code to 2 (unknown-command remains a distinct exit class).
|
|
518
|
+
// emitError() sets exitCode + returns rather than calling process.exit()
|
|
519
|
+
// so the stderr JSON drains before teardown; promote the exit code to 2
|
|
520
|
+
// afterwards (unknown-command remains a distinct exit class).
|
|
503
521
|
emitError(`unknown command "${cmd}"`, { hint: "Run `exceptd help` for the list of verbs.", verb: cmd });
|
|
504
522
|
process.exitCode = 2;
|
|
505
523
|
return;
|
|
@@ -507,7 +525,7 @@ function main() {
|
|
|
507
525
|
|
|
508
526
|
const script = resolver();
|
|
509
527
|
if (!fs.existsSync(script)) {
|
|
510
|
-
//
|
|
528
|
+
// emitError + exitCode rather than stderr + exit() so the JSON drains.
|
|
511
529
|
emitError(
|
|
512
530
|
`command "${cmd}" not available — expected ${path.relative(PKG_ROOT, script)} in the installed package.`,
|
|
513
531
|
{ verb: cmd }
|
|
@@ -521,7 +539,7 @@ function main() {
|
|
|
521
539
|
const finalArgs = ORCHESTRATOR_PASSTHROUGH.has(effectiveCmd) ? [script, effectiveCmd, ...effectiveRest] : [script, ...effectiveRest];
|
|
522
540
|
const res = spawnSync(process.execPath, finalArgs, { stdio: "inherit", cwd: PKG_ROOT });
|
|
523
541
|
if (res.error) {
|
|
524
|
-
//
|
|
542
|
+
// emitError + exitCode rather than stderr + exit() so the JSON drains.
|
|
525
543
|
emitError(`failed to run ${cmd}: ${res.error.message}`, { verb: cmd });
|
|
526
544
|
process.exitCode = 2;
|
|
527
545
|
return;
|
|
@@ -624,7 +642,7 @@ function emitError(msg, extra, pretty) {
|
|
|
624
642
|
}
|
|
625
643
|
|
|
626
644
|
/**
|
|
627
|
-
*
|
|
645
|
+
* Shared BOM-tolerant JSON file reader. Windows tools commonly emit
|
|
628
646
|
* UTF-8-BOM (EF BB BF) or UTF-16 LE/BE (FF FE / FE FF). The default
|
|
629
647
|
* `fs.readFileSync(path, "utf8")` chokes on the leading 0xFEFF (UTF-8-BOM
|
|
630
648
|
* becomes a literal BOM codepoint that `JSON.parse` refuses) and decodes
|
|
@@ -699,7 +717,7 @@ function readEvidence(evidenceFlag) {
|
|
|
699
717
|
if (stat.size > MAX_EVIDENCE_BYTES) {
|
|
700
718
|
throw new Error(`evidence file too large: ${stat.size} bytes > ${MAX_EVIDENCE_BYTES} byte limit. Reduce the submission or split into multiple playbook runs.`);
|
|
701
719
|
}
|
|
702
|
-
//
|
|
720
|
+
// Route through readJsonFile() for UTF-8-BOM / UTF-16 tolerance.
|
|
703
721
|
// Windows-tool-emitted JSON commonly carries these markers; the raw "utf8"
|
|
704
722
|
// decode in readFileSync chokes on the leading 0xFEFF.
|
|
705
723
|
return readJsonFile(evidenceFlag);
|
|
@@ -710,7 +728,7 @@ function loadRunner() {
|
|
|
710
728
|
}
|
|
711
729
|
|
|
712
730
|
/**
|
|
713
|
-
*
|
|
731
|
+
* Detect whether stdin actually has data without blocking.
|
|
714
732
|
*
|
|
715
733
|
* `!process.stdin.isTTY` (the previous heuristic) fires when isTTY is
|
|
716
734
|
* `false`, `undefined`, OR `null`. Test harnesses with custom stdin
|
|
@@ -742,13 +760,12 @@ function hasReadableStdin() {
|
|
|
742
760
|
let st;
|
|
743
761
|
try { st = fs.fstatSync(0); }
|
|
744
762
|
catch {
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// win32 when piped, so the strict check preserves that working case.
|
|
763
|
+
// fstat failed — on Windows require `isTTY === false` STRICTLY (not
|
|
764
|
+
// falsy). A non-strict check returns true when isTTY is undefined (e.g.
|
|
765
|
+
// Mocha/Jest test harnesses with a wrapped duplexer on Windows), which
|
|
766
|
+
// causes fs.readFileSync(0) to block indefinitely waiting on an EOF
|
|
767
|
+
// that never arrives. MSYS-bash piping on win32 sets isTTY === false,
|
|
768
|
+
// so the strict check still admits genuine piped input.
|
|
752
769
|
if (process.platform === "win32") return process.stdin.isTTY === false;
|
|
753
770
|
return false;
|
|
754
771
|
}
|
|
@@ -778,13 +795,11 @@ function hasReadableStdin() {
|
|
|
778
795
|
}
|
|
779
796
|
|
|
780
797
|
/**
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
* filtered the wrong years. Now: require an explicit calendar-date shape
|
|
787
|
-
* (YYYY-MM-DD with optional time component) BEFORE handing to Date.parse.
|
|
798
|
+
* ISO-8601 shape regex applied BEFORE Date.parse for --since flags. Without
|
|
799
|
+
* the regex check, bare integers like "99" coerce through Date.parse to
|
|
800
|
+
* 1999-12-01T00:00:00Z (two-digit-year heuristic), silently filtering the
|
|
801
|
+
* wrong years. Requires an explicit calendar-date shape (YYYY-MM-DD with
|
|
802
|
+
* optional time component) before handing to Date.parse.
|
|
788
803
|
*
|
|
789
804
|
* Returns null on success; returns the human-facing error message string
|
|
790
805
|
* on failure so the caller can wrap it with its own verb prefix.
|
|
@@ -798,7 +813,7 @@ function validateIsoSince(raw) {
|
|
|
798
813
|
}
|
|
799
814
|
|
|
800
815
|
/**
|
|
801
|
-
*
|
|
816
|
+
* Detect whether a parsed JSON document is plausibly CycloneDX VEX or
|
|
802
817
|
* OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
|
|
803
818
|
* anything else, which means an operator who passes SARIF / SBOM / CSAF /
|
|
804
819
|
* advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
|
|
@@ -820,12 +835,12 @@ function detectVexShape(doc) {
|
|
|
820
835
|
const isBom = doc.bomFormat === "CycloneDX";
|
|
821
836
|
const specStr = typeof doc.specVersion === "string" ? doc.specVersion : "";
|
|
822
837
|
const hasCyclonedxMarker = isBom || specStr.startsWith("1.");
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
838
|
+
// Empty vulnerabilities arrays cannot vouch for CycloneDX shape on their
|
|
839
|
+
// own — `{"bomFormat":"NOT-CycloneDX","vulnerabilities":[]}` would
|
|
840
|
+
// otherwise pass because `length === 0` trivially satisfies
|
|
826
841
|
// `entriesLookVex`. Require a real CycloneDX marker (bomFormat or
|
|
827
|
-
// specVersion) when the array is empty; non-empty arrays still pass
|
|
828
|
-
//
|
|
842
|
+
// specVersion) when the array is empty; non-empty arrays still pass when
|
|
843
|
+
// any entry has vex-shaped fields (id / bom-ref / analysis).
|
|
829
844
|
if (doc.vulnerabilities.length === 0) {
|
|
830
845
|
if (hasCyclonedxMarker) {
|
|
831
846
|
return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
|
|
@@ -850,8 +865,8 @@ function detectVexShape(doc) {
|
|
|
850
865
|
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
851
866
|
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
852
867
|
}
|
|
853
|
-
//
|
|
854
|
-
//
|
|
868
|
+
// A CycloneDX SBOM with no `vulnerabilities` key is a legitimate "0-CVE
|
|
869
|
+
// VEX filter" submission — the operator is asserting nothing here is
|
|
855
870
|
// exploitable. Accept it as cyclonedx-vex with an empty filter set (the
|
|
856
871
|
// runner's vexFilterFromDoc returns Set(0) for the same shape). Same logic
|
|
857
872
|
// for documents that carry a CycloneDX-flavored specVersion ("1.x") without
|
|
@@ -869,6 +884,10 @@ function detectVexShape(doc) {
|
|
|
869
884
|
}
|
|
870
885
|
|
|
871
886
|
function firstDirectiveId(runner, playbookId) {
|
|
887
|
+
// Defense-in-depth: callers that touch this helper directly (test
|
|
888
|
+
// harnesses, library consumers) still get path-traversal refusal.
|
|
889
|
+
const r = validateIdComponent(playbookId, "playbook");
|
|
890
|
+
if (!r.ok) throw new Error(`invalid playbook id (${r.reason}): ${typeof playbookId === "string" ? playbookId.slice(0, 80) : typeof playbookId}`);
|
|
872
891
|
const pb = runner.loadPlaybook(playbookId);
|
|
873
892
|
if (!pb.directives || !pb.directives.length) {
|
|
874
893
|
throw new Error(`Playbook ${playbookId} has no directives.`);
|
|
@@ -894,7 +913,9 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
894
913
|
// gate alongside --signatures. doctor --registry-check + --signatures
|
|
895
914
|
// were already accepted; explicit registration removes the silent
|
|
896
915
|
// "unknown bool flag" surface in parseArgs.
|
|
897
|
-
"shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs"
|
|
916
|
+
"shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs",
|
|
917
|
+
// doctor --exit-codes dumps the canonical exit-code table.
|
|
918
|
+
"exit-codes"],
|
|
898
919
|
multi: ["playbook", "format"],
|
|
899
920
|
});
|
|
900
921
|
// v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
|
|
@@ -911,19 +932,111 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
911
932
|
// Hoist into module-level state so emit() can read it without plumbing.
|
|
912
933
|
global.__exceptdWantJson = args._jsonMode;
|
|
913
934
|
const pretty = !!args.pretty;
|
|
935
|
+
|
|
936
|
+
// Flag-typo defense: anything supplied by the operator that isn't on the
|
|
937
|
+
// verb's allowlist gets a Levenshtein suggestion + immediate refusal.
|
|
938
|
+
// Pre-fix, `exceptd run --evidnce ev.json` silently absorbed --evidnce as
|
|
939
|
+
// a boolean flag and produced a cryptic downstream error when the runner
|
|
940
|
+
// got no evidence. Now: refuse at the dispatcher with the suggested
|
|
941
|
+
// correct flag so operators see the typo before any side effects run.
|
|
942
|
+
//
|
|
943
|
+
// Ignore parser-internal scratch keys (`_jsonMode`, leading-underscore) +
|
|
944
|
+
// the bare-positional bucket (`_`). REQUIRES_VALUE catches the
|
|
945
|
+
// value-bearing flags that parsed as boolean true (i.e. the operator
|
|
946
|
+
// forgot the value).
|
|
947
|
+
// Value-bearing flags only. Boolean flags (--ack, --latest, --force-replay,
|
|
948
|
+
// --force-stale, --ci, --pretty, etc.) are intentionally absent because
|
|
949
|
+
// their `true` parse is the canonical operator intent.
|
|
950
|
+
const REQUIRES_VALUE = new Set([
|
|
951
|
+
"evidence", "evidence-dir", "session-id", "operator", "csaf-status",
|
|
952
|
+
"publisher-namespace", "mode", "scope", "playbook", "phase", "tlp",
|
|
953
|
+
"against", "since", "bundle-epoch", "attestation-root", "format",
|
|
954
|
+
]);
|
|
955
|
+
const verbAllowlist = flagsFor(cmd);
|
|
956
|
+
const allowlistSet = new Set(verbAllowlist);
|
|
957
|
+
// Internal-passthrough flags used by the parser / dispatcher that aren't
|
|
958
|
+
// in the operator-facing allowlist but must not trigger the typo check.
|
|
959
|
+
// The allowlist in lib/flag-suggest.js is operator-facing-only — these
|
|
960
|
+
// are the legacy/internal escape hatches that still need to flow
|
|
961
|
+
// through without a refusal.
|
|
962
|
+
const PASSTHROUGH_FLAGS = new Set([
|
|
963
|
+
"directive", "domain", "phase", "signal-list", "explain",
|
|
964
|
+
"signatures", "currency", "cves", "rfcs", "shipped-tarball",
|
|
965
|
+
"human", "json-stdout-only", "max-rwep", "diff-from-latest",
|
|
966
|
+
"upstream-check", "latest", "force-replay", "flat", "directives",
|
|
967
|
+
"fix", "session-key", "all", "scope", "playbook",
|
|
968
|
+
]);
|
|
969
|
+
for (const key of Object.keys(args)) {
|
|
970
|
+
if (key === "_" || key.startsWith("_")) continue;
|
|
971
|
+
// Per-verb help is universal even when not in the allowlist.
|
|
972
|
+
if (key === "help" || key === "h") continue;
|
|
973
|
+
if (PASSTHROUGH_FLAGS.has(key)) {
|
|
974
|
+
if (REQUIRES_VALUE.has(key) && args[key] === true) {
|
|
975
|
+
return emitError(
|
|
976
|
+
`${cmd}: --${key} requires a value.`,
|
|
977
|
+
{ verb: cmd, flag: key },
|
|
978
|
+
pretty
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
if (allowlistSet.has(key)) {
|
|
984
|
+
if (REQUIRES_VALUE.has(key) && args[key] === true) {
|
|
985
|
+
return emitError(
|
|
986
|
+
`${cmd}: --${key} requires a value.`,
|
|
987
|
+
{ verb: cmd, flag: key },
|
|
988
|
+
pretty
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
// Refuse only when a close suggestion exists (likely typo). Unknown
|
|
994
|
+
// flags with no near-match fall through to verb-level handling so a
|
|
995
|
+
// future addition doesn't require an allowlist edit in this file
|
|
996
|
+
// before it can ship. The PASSTHROUGH_FLAGS list above plus the
|
|
997
|
+
// per-verb allowlist in lib/flag-suggest.js together cover every
|
|
998
|
+
// shipped flag; anything that misses both AND has a typo suggestion
|
|
999
|
+
// is the case operators benefit from refusing.
|
|
1000
|
+
const suggestion = suggestFlag(key, verbAllowlist);
|
|
1001
|
+
if (suggestion) {
|
|
1002
|
+
return emitError(
|
|
1003
|
+
`unknown flag --${key}`,
|
|
1004
|
+
{ verb: cmd, suggested: suggestion },
|
|
1005
|
+
pretty
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
914
1009
|
const runOpts = {
|
|
915
|
-
|
|
1010
|
+
// Air-gap can be requested via the explicit flag OR the
|
|
1011
|
+
// EXCEPTD_AIR_GAP=1 environment variable. The env-var path is for
|
|
1012
|
+
// operators who export it once at shell-init time so every subsequent
|
|
1013
|
+
// invocation inherits the disposition without remembering the flag.
|
|
1014
|
+
airGap: !!args["air-gap"] || process.env.EXCEPTD_AIR_GAP === "1",
|
|
916
1015
|
forceStale: !!args["force-stale"],
|
|
917
1016
|
};
|
|
1017
|
+
// Air-gap advisory (one-time per process). Routed to stderr so JSON
|
|
1018
|
+
// consumers on stdout don't see it. The exceptd CLI does not perform
|
|
1019
|
+
// network egress in air-gap mode, but a host AI driving exceptd may
|
|
1020
|
+
// still call its model API — surface the boundary so operators verify
|
|
1021
|
+
// their agent runtime is offline too.
|
|
1022
|
+
if (runOpts.airGap && !process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN) {
|
|
1023
|
+
process.stderr.write(
|
|
1024
|
+
`[exceptd] air-gap: exceptd will not perform network egress. Your AI agent may still call its model API; verify your agent runtime is also offline.\n`
|
|
1025
|
+
);
|
|
1026
|
+
process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN = "1";
|
|
1027
|
+
}
|
|
918
1028
|
if (args["session-id"]) {
|
|
919
|
-
//
|
|
1029
|
+
// --session-id is a filesystem path component (resolves to
|
|
920
1030
|
// .exceptd/attestations/<id>/attestation.json). Operator-supplied input
|
|
921
|
-
// with `..` or path separators escapes the attestation root.
|
|
922
|
-
//
|
|
1031
|
+
// with `..` or path separators escapes the attestation root. Route
|
|
1032
|
+
// through the shared validateIdComponent('session') helper so the regex
|
|
1033
|
+
// + all-dots refusal stay aligned with persistAttestation /
|
|
1034
|
+
// validateSessionIdForRead.
|
|
923
1035
|
const sid = args["session-id"];
|
|
924
|
-
|
|
1036
|
+
const r = validateIdComponent(sid, "session");
|
|
1037
|
+
if (!r.ok) {
|
|
925
1038
|
return emitError(
|
|
926
|
-
|
|
1039
|
+
`run: --session-id ${r.reason}. Path separators and '..' are rejected.`,
|
|
927
1040
|
{ provided: typeof sid === "string" ? sid.slice(0, 80) : typeof sid },
|
|
928
1041
|
pretty
|
|
929
1042
|
);
|
|
@@ -939,13 +1052,28 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
939
1052
|
if (typeof ar !== "string" || ar.length === 0) {
|
|
940
1053
|
return emitError("run: --attestation-root must be a non-empty string.", { provided: typeof ar }, pretty);
|
|
941
1054
|
}
|
|
942
|
-
|
|
1055
|
+
const arSegments = ar.split(/[\\/]/);
|
|
1056
|
+
if (arSegments.some(seg => seg === "..")) {
|
|
943
1057
|
return emitError(
|
|
944
1058
|
"run: --attestation-root must not contain '..' path segments. Pass an absolute path under your home directory or an explicit project-relative path without traversal.",
|
|
945
1059
|
{ provided: ar.slice(0, 200) },
|
|
946
1060
|
pretty
|
|
947
1061
|
);
|
|
948
1062
|
}
|
|
1063
|
+
// All-dots segments (`.`, `..`, `...`, etc.) all resolve into or above
|
|
1064
|
+
// the intended parent directory, defeating the attestation-root
|
|
1065
|
+
// confinement check. Refuse any non-empty segment that is entirely dots
|
|
1066
|
+
// — the leading-`.` empty segment of an absolute POSIX path is allowed,
|
|
1067
|
+
// and a single `.` mid-path means "this dir" but is collapsed by
|
|
1068
|
+
// path.resolve anyway; explicit refusal is cheaper than reasoning about
|
|
1069
|
+
// every collapsed-equivalent shape.
|
|
1070
|
+
if (arSegments.some(seg => seg.length > 0 && /^\.+$/.test(seg))) {
|
|
1071
|
+
return emitError(
|
|
1072
|
+
"run: --attestation-root path segment cannot consist entirely of dots (rejected: '.', '..', '...', etc.). Pass an absolute path or a project-relative path without traversal.",
|
|
1073
|
+
{ provided: ar.slice(0, 200) },
|
|
1074
|
+
pretty
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
949
1077
|
runOpts.attestationRoot = path.resolve(ar);
|
|
950
1078
|
}
|
|
951
1079
|
if (args["session-key"]) {
|
|
@@ -973,12 +1101,12 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
973
1101
|
// service identity. --operator <name> persists into the attestation file
|
|
974
1102
|
// for audit-trail accountability.
|
|
975
1103
|
//
|
|
976
|
-
//
|
|
977
|
-
//
|
|
978
|
-
// into attestation export output (multi-line "operator:" key/value
|
|
979
|
-
// are a forgery surface — a forged second line could look like a
|
|
980
|
-
// attestation field to a naive parser).
|
|
981
|
-
// (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
|
|
1104
|
+
// Validate the input. Without this, a value flows into runOpts unchanged
|
|
1105
|
+
// and an operator could inject newlines / control chars / arbitrary
|
|
1106
|
+
// length into attestation export output (multi-line "operator:" key/value
|
|
1107
|
+
// pairs are a forgery surface — a forged second line could look like a
|
|
1108
|
+
// separate attestation field to a naive parser). Strip ASCII control
|
|
1109
|
+
// chars (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
|
|
982
1110
|
if (args.operator !== undefined) {
|
|
983
1111
|
if (typeof args.operator !== "string") {
|
|
984
1112
|
return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
|
|
@@ -1005,8 +1133,8 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1005
1133
|
pretty
|
|
1006
1134
|
);
|
|
1007
1135
|
}
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1136
|
+
// The ASCII-only control-char regex above misses Unicode categories
|
|
1137
|
+
// Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
|
|
1010
1138
|
// zero-width joiners (U+200B-D), invisible format chars, private-use
|
|
1011
1139
|
// codepoints, unassigned codepoints. An operator string like
|
|
1012
1140
|
// "aliceevilbob" renders as "alicebobevila" in any UI that respects
|
|
@@ -1055,15 +1183,15 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1055
1183
|
runOpts.operator = normalized;
|
|
1056
1184
|
}
|
|
1057
1185
|
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1186
|
+
// --csaf-status and --publisher-namespace shape the CSAF bundle emitted by
|
|
1187
|
+
// phases 5-7. Verbs that don't drive those phases (brief, plan, govern,
|
|
1188
|
+
// direct, look, attest, list-attestations, discover, doctor, lint, ask,
|
|
1189
|
+
// verify-attestation, reattest) never assemble a bundle, so silently
|
|
1190
|
+
// consuming these flags is a UX trap. Refuse on those verbs so the
|
|
1191
|
+
// operator knows the flag was discarded — same pattern as --ack. Error
|
|
1192
|
+
// message templates and emitError prefixes use the in-scope `cmd` verb so
|
|
1193
|
+
// a brief invocation says "brief:" rather than misattributing the flag
|
|
1194
|
+
// to run.
|
|
1067
1195
|
const BUNDLE_FLAG_RELEVANT_VERBS = new Set([
|
|
1068
1196
|
"run", "ci", "run-all", "ai-run", "ingest",
|
|
1069
1197
|
]);
|
|
@@ -1143,13 +1271,13 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1143
1271
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
1144
1272
|
// and wait for ack — this is how the ack gets recorded.
|
|
1145
1273
|
//
|
|
1146
|
-
//
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1274
|
+
// --ack only makes sense on verbs that drive phases 5-7 (run / ingest /
|
|
1275
|
+
// ai-run / ci / run-all / reattest). Info-only verbs (brief, plan,
|
|
1276
|
+
// govern, direct, look, attest, list-attestations, discover, doctor,
|
|
1277
|
+
// lint, ask, verify-attestation) never consume an attestation clock —
|
|
1278
|
+
// accepting --ack silently is a UX trap where operators believe they have
|
|
1279
|
+
// recorded consent. Refuse on those verbs so the operator knows the flag
|
|
1280
|
+
// is irrelevant.
|
|
1153
1281
|
const ACK_RELEVANT_VERBS = new Set([
|
|
1154
1282
|
"run", "ingest", "ai-run", "ci", "run-all", "reattest",
|
|
1155
1283
|
]);
|
|
@@ -1206,7 +1334,15 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1206
1334
|
return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
|
|
1207
1335
|
}
|
|
1208
1336
|
}
|
|
1209
|
-
|
|
1337
|
+
// Wrap bare e.message so operators see the verb that triggered the
|
|
1338
|
+
// failure + the next action they can take. Re-running with --pretty
|
|
1339
|
+
// expands the cause for log-scraping; the GitHub-issues pointer lets
|
|
1340
|
+
// operators report reproducible-but-unhandled exceptions.
|
|
1341
|
+
emitError(
|
|
1342
|
+
`${cmd}: internal error (${e && e.message ? e.message : String(e)}). Re-run with --pretty for context; file at https://github.com/blamejs/exceptd-skills/issues if reproducible.`,
|
|
1343
|
+
{ verb: cmd },
|
|
1344
|
+
pretty
|
|
1345
|
+
);
|
|
1210
1346
|
}
|
|
1211
1347
|
}
|
|
1212
1348
|
|
|
@@ -1413,19 +1549,50 @@ Exit codes (per-verb, post-run):
|
|
|
1413
1549
|
3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
|
|
1414
1550
|
4 Blocked Result returned ok:false (preflight halt).
|
|
1415
1551
|
5 CLOCK_STARTED --block-on-jurisdiction-clock fired.
|
|
1552
|
+
6 TAMPERED Surfaced by attest verify; sidecar verification failed.
|
|
1553
|
+
7 SESSION_ID_COLLISION run --session-id duplicate without --force-overwrite.
|
|
1416
1554
|
8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
|
|
1417
1555
|
attestation lock after the bounded retry budget
|
|
1418
1556
|
(~1-2s). Distinct from 1 so callers can retry the
|
|
1419
1557
|
operation rather than treat it as a hard failure.
|
|
1420
1558
|
Surfaces as body.lock_contention=true,
|
|
1421
|
-
body.exit_code=8
|
|
1559
|
+
body.exit_code=8.
|
|
1560
|
+
9 STORAGE_EXHAUSTED Attestation write hit ENOSPC / EDQUOT / EROFS.
|
|
1561
|
+
|
|
1562
|
+
Other operator-facing flags (full list in source; surfaced here for grep):
|
|
1563
|
+
--vex <file> CycloneDX / OpenVEX disposition filter.
|
|
1564
|
+
--evidence-dir <dir> Per-playbook submission files.
|
|
1565
|
+
--attestation-root <p> Override .exceptd/ root for this run.
|
|
1566
|
+
--mode <m> Investigation mode (self_service | authorized_pentest
|
|
1567
|
+
| ir_response | ctf | research | compliance_audit).`,
|
|
1422
1568
|
ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
|
|
1423
1569
|
|
|
1424
1570
|
Flags:
|
|
1425
1571
|
--domain <id> Playbook ID (overrides submission.playbook_id).
|
|
1426
1572
|
--directive <id> Directive ID (overrides submission.directive_id).
|
|
1427
1573
|
--evidence <file|-> Submission JSON. May include playbook_id/directive_id.
|
|
1428
|
-
--
|
|
1574
|
+
--session-id <id> Reuse a specific session id (must satisfy
|
|
1575
|
+
/^[A-Za-z0-9._-]{1,64}$/).
|
|
1576
|
+
--force-overwrite Override session-id collision refusal.
|
|
1577
|
+
--operator <name> Bind attestation to a specific identity.
|
|
1578
|
+
--ack Explicit operator consent for jurisdiction clock.
|
|
1579
|
+
--attestation-root <p> Override .exceptd/ root for this ingest.
|
|
1580
|
+
--mode <m> Investigation mode (self_service | authorized_pentest
|
|
1581
|
+
| ir_response | ctf | research | compliance_audit).
|
|
1582
|
+
--air-gap Honor air_gap_alternative paths.
|
|
1583
|
+
--force-stale Override threat_currency_score<50 gate.
|
|
1584
|
+
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1585
|
+
bundle. One of: draft | interim (default) | final.
|
|
1586
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1587
|
+
set this only after operator review of the advisory.
|
|
1588
|
+
--publisher-namespace <url>
|
|
1589
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1590
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1591
|
+
Must be an http://… or https://… URL, ≤256 chars.
|
|
1592
|
+
--pretty Indented JSON output.
|
|
1593
|
+
|
|
1594
|
+
Exit codes: 0 PASS, 1 framework, 4 blocked, 7 SESSION_ID_COLLISION,
|
|
1595
|
+
8 LOCK_CONTENTION, 9 STORAGE_EXHAUSTED.`,
|
|
1429
1596
|
reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
|
|
1430
1597
|
|
|
1431
1598
|
Args / flags:
|
|
@@ -1435,7 +1602,12 @@ Args / flags:
|
|
|
1435
1602
|
--since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
|
|
1436
1603
|
--pretty Indented JSON output.
|
|
1437
1604
|
|
|
1438
|
-
Reports: unchanged | drifted | resolved from evidence_hash + classification deltas
|
|
1605
|
+
Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.
|
|
1606
|
+
|
|
1607
|
+
Exit codes:
|
|
1608
|
+
0 verification succeeded
|
|
1609
|
+
1 generic failure
|
|
1610
|
+
6 TAMPERED (sidecar or signature mismatch on the prior attestation)`,
|
|
1439
1611
|
"list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
|
|
1440
1612
|
|
|
1441
1613
|
Args / flags:
|
|
@@ -1446,26 +1618,38 @@ Lists every attestation under .exceptd/attestations/<session_id>/, sorted
|
|
|
1446
1618
|
newest-first, with truncated evidence_hash + capture timestamp + file path.`,
|
|
1447
1619
|
attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
|
|
1448
1620
|
|
|
1449
|
-
Subverbs:
|
|
1621
|
+
Subverbs (list | show | export | verify | diff):
|
|
1450
1622
|
attest show <sid> Emit the full (unredacted) attestation.
|
|
1451
1623
|
attest list Inventory every prior attestation under
|
|
1452
1624
|
~/.exceptd/attestations/ (or EXCEPTD_HOME when set).
|
|
1453
|
-
Filter with --playbook <id> or --since <ISO
|
|
1454
|
-
|
|
1455
|
-
|
|
1625
|
+
Filter with --playbook <id> or --since <ISO> (must
|
|
1626
|
+
be a parseable ISO-8601 timestamp). Newest first;
|
|
1627
|
+
truncated evidence_hash + capture timestamp + path
|
|
1628
|
+
per entry.
|
|
1456
1629
|
attest export <sid> Emit redacted JSON suitable for audit submission.
|
|
1457
1630
|
Strips raw artifact values; preserves evidence_hash,
|
|
1458
1631
|
signature, classification, RWEP, remediation choice.
|
|
1459
|
-
--format <csaf|
|
|
1460
|
-
|
|
1632
|
+
--format <csaf|csaf-2.0|json> wraps the export
|
|
1633
|
+
(default: redacted JSON; csaf yields a CSAF 2.0
|
|
1634
|
+
envelope).
|
|
1461
1635
|
attest verify <sid> Verify .sig sidecar against keys/public.pem.
|
|
1462
|
-
Reports tamper status per attestation file.
|
|
1636
|
+
Reports tamper status per attestation file. Replay
|
|
1637
|
+
records (kind=replay) verify under replay_results;
|
|
1638
|
+
a replay-record tamper raises body.replay_tamper +
|
|
1639
|
+
warnings[] but does NOT exit non-zero (the audit
|
|
1640
|
+
trail can be regenerated via reattest).
|
|
1463
1641
|
attest diff <sid> Diff <sid> against the most-recent prior attestation
|
|
1464
1642
|
for the same playbook, or against --against <other-sid>
|
|
1465
1643
|
for an explicit pair. Reports unchanged | drifted |
|
|
1466
1644
|
resolved per evidence_hash + classification deltas.
|
|
1467
1645
|
|
|
1468
|
-
All subverbs honor --pretty for indented JSON output
|
|
1646
|
+
All subverbs honor --pretty for indented JSON output.
|
|
1647
|
+
|
|
1648
|
+
Exit codes (attest verify):
|
|
1649
|
+
0 verification succeeded
|
|
1650
|
+
1 generic failure
|
|
1651
|
+
6 TAMPERED (sidecar or signature mismatch on an attestation; replay-record
|
|
1652
|
+
tamper warns but exits 0)`,
|
|
1469
1653
|
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
1470
1654
|
|
|
1471
1655
|
Replaces: scan + dispatch + recommend.
|
|
@@ -1480,7 +1664,11 @@ Flags:
|
|
|
1480
1664
|
--json Emit JSON (default is human-readable text).
|
|
1481
1665
|
--pretty Indented JSON output (implies --json).
|
|
1482
1666
|
|
|
1483
|
-
Output: context + recommended_playbooks[] + next_steps[]
|
|
1667
|
+
Output: context + recommended_playbooks[] + next_steps[].
|
|
1668
|
+
|
|
1669
|
+
discover always exits 0 (recommendations are informational; absence of a
|
|
1670
|
+
match is not a failure). JSON output is the canonical surface — humans see
|
|
1671
|
+
a digest by default; pass --json for the structured shape.`,
|
|
1484
1672
|
doctor: `doctor — one-shot health check (v0.11.0).
|
|
1485
1673
|
|
|
1486
1674
|
Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
|
|
@@ -1520,8 +1708,29 @@ Flags:
|
|
|
1520
1708
|
--directive <id> Specific directive (default: first one).
|
|
1521
1709
|
--no-stream Single-shot mode: emit all phases as one JSON doc
|
|
1522
1710
|
without reading stdin (uses runner.run directly).
|
|
1711
|
+
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1712
|
+
bundle. One of: draft | interim (default) | final.
|
|
1713
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1714
|
+
set this only after operator review of the advisory.
|
|
1715
|
+
--publisher-namespace <url>
|
|
1716
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1717
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1718
|
+
Must be an http://… or https://… URL, ≤256 chars.
|
|
1719
|
+
--evidence <file|-> Single-shot mode: pre-supplied submission JSON.
|
|
1720
|
+
--operator <name> Bind the attestation to a specific identity.
|
|
1721
|
+
--ack Mark explicit operator consent (jurisdiction clock).
|
|
1722
|
+
--force-overwrite Override session-id collision refusal.
|
|
1723
|
+
--session-id <id> Reuse a specific session id (must satisfy
|
|
1724
|
+
/^[A-Za-z0-9._-]{1,64}$/).
|
|
1523
1725
|
--pretty Indented JSON output (single-shot only).
|
|
1524
1726
|
|
|
1727
|
+
Exit codes:
|
|
1728
|
+
0 done Run completed; emitted {"event":"done","ok":true}.
|
|
1729
|
+
1 framework error Engine threw or stdin parse failure.
|
|
1730
|
+
3 SESSION_ID_COLLISION --session-id duplicate; pass --force-overwrite or fresh id.
|
|
1731
|
+
8 LOCK_CONTENTION Concurrent persistAttestation lock held.
|
|
1732
|
+
9 STORAGE_EXHAUSTED Disk/quota/RO filesystem on attestation write.
|
|
1733
|
+
|
|
1525
1734
|
Stdin event grammar (one JSON object per line):
|
|
1526
1735
|
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
1527
1736
|
|
|
@@ -1555,7 +1764,9 @@ Args / flags:
|
|
|
1555
1764
|
|
|
1556
1765
|
Output: { verb, question, routed_to:[ids], confidence, next_step,
|
|
1557
1766
|
full_match_list }. Empty match list when no token overlap — surfaces a
|
|
1558
|
-
hint pointing at \`exceptd brief --all\` / \`exceptd discover
|
|
1767
|
+
hint pointing at \`exceptd brief --all\` / \`exceptd discover\`.
|
|
1768
|
+
|
|
1769
|
+
ask always exits 0. JSON via --json (default is a one-line digest on TTY).`,
|
|
1559
1770
|
ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
|
|
1560
1771
|
|
|
1561
1772
|
Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
|
|
@@ -1601,6 +1812,9 @@ Exit codes:
|
|
|
1601
1812
|
regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
|
|
1602
1813
|
etc.) and the operator has not acked.
|
|
1603
1814
|
|
|
1815
|
+
(ci does not persist attestations per-run; exit codes 6/7/8/9 surface on
|
|
1816
|
+
\`attest verify\` and on \`run\` / \`ai-run\` / \`ingest\`, not on \`ci\`.)
|
|
1817
|
+
|
|
1604
1818
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
1605
1819
|
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
1606
1820
|
results[].`,
|
|
@@ -1652,7 +1866,15 @@ Flags: --pretty.`,
|
|
|
1652
1866
|
Identical exit-code and output contract as \`run --all\`. Maintained for
|
|
1653
1867
|
operators who script the verb form rather than the flag.
|
|
1654
1868
|
|
|
1655
|
-
|
|
1869
|
+
Flags (selected — see \`exceptd run --help\` for the full list):
|
|
1870
|
+
--csaf-status <s> CSAF tracking.status for per-run close.evidence_package
|
|
1871
|
+
bundles. One of: draft | interim (default) | final.
|
|
1872
|
+
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
1873
|
+
set this only after operator review of the advisory.
|
|
1874
|
+
--publisher-namespace <url>
|
|
1875
|
+
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1876
|
+
operator's organisation URL, NOT the tooling vendor.
|
|
1877
|
+
Must be an http://… or https://… URL, ≤256 chars.`,
|
|
1656
1878
|
};
|
|
1657
1879
|
process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
|
|
1658
1880
|
}
|
|
@@ -1683,9 +1905,22 @@ function cmdLint(runner, args, runOpts, pretty) {
|
|
|
1683
1905
|
if (!playbookId || !evidencePath) {
|
|
1684
1906
|
return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
|
|
1685
1907
|
}
|
|
1908
|
+
if (refuseInvalidPlaybookId("lint", playbookId, pretty)) return;
|
|
1686
1909
|
let pb;
|
|
1687
1910
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
1688
|
-
catch (e) {
|
|
1911
|
+
catch (e) {
|
|
1912
|
+
// Route the not-found / load-error case through the skill-to-playbook
|
|
1913
|
+
// hint helper so an operator who typed a skill id (kernel-lpe-triage)
|
|
1914
|
+
// gets the same actionable pointer dispatchPlaybook surfaces for cmdRun.
|
|
1915
|
+
const m = e && e.message && e.message.match(/^Playbook not found: ([^\s(]+)/);
|
|
1916
|
+
if (m) {
|
|
1917
|
+
const hint = buildSkillToPlaybookHint(runner, m[1]);
|
|
1918
|
+
if (hint) {
|
|
1919
|
+
return emitError(`lint: Playbook not found: "${m[1]}". ${hint}`, { playbook: playbookId, type: "playbook_not_found" }, pretty);
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
return emitError(`lint: ${e.message}`, { playbook: playbookId }, pretty);
|
|
1923
|
+
}
|
|
1689
1924
|
|
|
1690
1925
|
let submission;
|
|
1691
1926
|
try { submission = readEvidence(evidencePath); }
|
|
@@ -1824,6 +2059,7 @@ function cmdBrief(runner, args, runOpts, pretty) {
|
|
|
1824
2059
|
return cmdPlan(runner, args, runOpts, pretty);
|
|
1825
2060
|
}
|
|
1826
2061
|
|
|
2062
|
+
if (refuseInvalidPlaybookId("brief", playbookId, pretty)) return;
|
|
1827
2063
|
const pb = runner.loadPlaybook(playbookId);
|
|
1828
2064
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
1829
2065
|
|
|
@@ -1982,6 +2218,41 @@ function validateScopeOrThrow(scope) {
|
|
|
1982
2218
|
return scope;
|
|
1983
2219
|
}
|
|
1984
2220
|
|
|
2221
|
+
/**
|
|
2222
|
+
* Wrap every operator-controlled loadPlaybook() call so a path-traversal
|
|
2223
|
+
* shaped id (`../../etc/passwd`, `..`, absolute path) is refused at the
|
|
2224
|
+
* dispatcher before the runner ever sees it. Routes through
|
|
2225
|
+
* validateIdComponent('playbook'), which enforces /^[a-z][a-z0-9-]{0,63}$/.
|
|
2226
|
+
* On failure returns the structured emitError shape; on success returns
|
|
2227
|
+
* null so the caller can short-circuit with a single `if (refusal) return refusal;`.
|
|
2228
|
+
*/
|
|
2229
|
+
function refuseInvalidPlaybookId(verb, playbookId, pretty) {
|
|
2230
|
+
const r = validateIdComponent(playbookId, "playbook");
|
|
2231
|
+
if (!r.ok) {
|
|
2232
|
+
emitError(
|
|
2233
|
+
`${verb}: invalid <playbook> id — ${r.reason}.`,
|
|
2234
|
+
{ verb, provided: typeof playbookId === "string" ? playbookId.slice(0, 80) : typeof playbookId },
|
|
2235
|
+
pretty
|
|
2236
|
+
);
|
|
2237
|
+
return true;
|
|
2238
|
+
}
|
|
2239
|
+
return false;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* Shared "playbook has no directives" refusal. Six sites in this file
|
|
2244
|
+
* previously hand-rolled the same error string; consolidating means a
|
|
2245
|
+
* future remediation pointer (e.g. "run `exceptd brief <id>` to inspect
|
|
2246
|
+
* the playbook") changes in one place.
|
|
2247
|
+
*/
|
|
2248
|
+
function refuseNoDirectives(verb, playbookId, pretty) {
|
|
2249
|
+
return emitError(
|
|
2250
|
+
`${verb}: playbook ${playbookId} has no directives. Inspect the playbook with \`exceptd brief ${playbookId}\` or report at https://github.com/blamejs/exceptd-skills/issues.`,
|
|
2251
|
+
{ verb, playbook: playbookId },
|
|
2252
|
+
pretty
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
|
|
1985
2256
|
function filterPlaybooksByScope(runner, scope) {
|
|
1986
2257
|
validateScopeOrThrow(scope);
|
|
1987
2258
|
const ids = runner.listPlaybooks();
|
|
@@ -2024,27 +2295,30 @@ function detectScopes() {
|
|
|
2024
2295
|
function cmdGovern(runner, args, runOpts, pretty) {
|
|
2025
2296
|
const playbookId = args._[0];
|
|
2026
2297
|
if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
|
|
2298
|
+
if (refuseInvalidPlaybookId("govern", playbookId, pretty)) return;
|
|
2027
2299
|
const pb = runner.loadPlaybook(playbookId);
|
|
2028
2300
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2029
|
-
if (!directiveId) return
|
|
2301
|
+
if (!directiveId) return refuseNoDirectives("govern", playbookId, pretty);
|
|
2030
2302
|
emit(runner.govern(playbookId, directiveId, runOpts), pretty);
|
|
2031
2303
|
}
|
|
2032
2304
|
|
|
2033
2305
|
function cmdDirect(runner, args, pretty) {
|
|
2034
2306
|
const playbookId = args._[0];
|
|
2035
2307
|
if (!playbookId) return emitError("direct: missing <playbookId> positional argument.", null, pretty);
|
|
2308
|
+
if (refuseInvalidPlaybookId("direct", playbookId, pretty)) return;
|
|
2036
2309
|
const pb = runner.loadPlaybook(playbookId);
|
|
2037
2310
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2038
|
-
if (!directiveId) return
|
|
2311
|
+
if (!directiveId) return refuseNoDirectives("direct", playbookId, pretty);
|
|
2039
2312
|
emit(runner.direct(playbookId, directiveId), pretty);
|
|
2040
2313
|
}
|
|
2041
2314
|
|
|
2042
2315
|
function cmdLook(runner, args, runOpts, pretty) {
|
|
2043
2316
|
const playbookId = args._[0];
|
|
2044
2317
|
if (!playbookId) return emitError("look: missing <playbookId> positional argument.", null, pretty);
|
|
2318
|
+
if (refuseInvalidPlaybookId("look", playbookId, pretty)) return;
|
|
2045
2319
|
const pb = runner.loadPlaybook(playbookId);
|
|
2046
2320
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2047
|
-
if (!directiveId) return
|
|
2321
|
+
if (!directiveId) return refuseNoDirectives("look", playbookId, pretty);
|
|
2048
2322
|
emit(runner.look(playbookId, directiveId, runOpts), pretty);
|
|
2049
2323
|
}
|
|
2050
2324
|
|
|
@@ -2054,13 +2328,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2054
2328
|
// Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
|
|
2055
2329
|
// a bare `exceptd run` (no positional, no flags) which auto-detects scopes
|
|
2056
2330
|
// from the cwd.
|
|
2057
|
-
//
|
|
2058
|
-
//
|
|
2059
|
-
//
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
// message, matching the rest of the v0.12.15 scope-validation contract.
|
|
2331
|
+
// Gate on `args.scope !== undefined` rather than truthy `args.scope`.
|
|
2332
|
+
// `--scope ""` parses to `args.scope === ""`, which is falsy; a truthy
|
|
2333
|
+
// gate would silently fall through to auto-detect and run whatever
|
|
2334
|
+
// scopes happened to match the cwd, masking the operator's explicit
|
|
2335
|
+
// (if malformed) intent. An empty string reaches validateScopeOrThrow
|
|
2336
|
+
// which rejects with the accepted-set message.
|
|
2064
2337
|
if (!positional && (args.all || args.scope !== undefined)) {
|
|
2065
2338
|
let ids;
|
|
2066
2339
|
if (args.all) {
|
|
@@ -2076,16 +2349,32 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2076
2349
|
const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
|
2077
2350
|
const unique = [...new Set(ids)];
|
|
2078
2351
|
if (unique.length === 0) {
|
|
2079
|
-
|
|
2352
|
+
// Surface the auto-detect failure cause so operators see WHY no
|
|
2353
|
+
// playbook was resolved instead of just "nothing matched." Mirrors
|
|
2354
|
+
// detectScopes()' two probes — `.git/` for code, `/proc + os-release`
|
|
2355
|
+
// for system — and enumerates the accepted explicit flags so the
|
|
2356
|
+
// remediation is one line.
|
|
2357
|
+
const hasGit = fs.existsSync(path.join(process.cwd(), ".git"));
|
|
2358
|
+
const hasProc = fs.existsSync("/proc") && fs.existsSync("/etc/os-release");
|
|
2359
|
+
const probes = [];
|
|
2360
|
+
if (!hasGit) probes.push("no .git/ in cwd (code-scope auto-detect skipped)");
|
|
2361
|
+
if (!hasProc) probes.push("no /proc + /etc/os-release (system-scope auto-detect skipped — not a Linux host or under sandbox)");
|
|
2362
|
+
const reason = probes.length ? ` Auto-detect probes: ${probes.join("; ")}.` : "";
|
|
2363
|
+
return emitError(
|
|
2364
|
+
`run: no playbook resolved. Pass <playbookId>, --scope <type> (one of ${JSON.stringify(VALID_SCOPES)}), or --all.${reason}`,
|
|
2365
|
+
{ verb: "run", cwd: process.cwd(), detected_scopes: scopes },
|
|
2366
|
+
pretty
|
|
2367
|
+
);
|
|
2080
2368
|
}
|
|
2081
2369
|
return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
|
|
2082
2370
|
}
|
|
2083
2371
|
|
|
2084
2372
|
// Single-playbook path (existing behavior).
|
|
2085
2373
|
const playbookId = positional;
|
|
2374
|
+
if (refuseInvalidPlaybookId("run", playbookId, pretty)) return;
|
|
2086
2375
|
const pb = runner.loadPlaybook(playbookId);
|
|
2087
2376
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2088
|
-
if (!directiveId) return
|
|
2377
|
+
if (!directiveId) return refuseNoDirectives("run", playbookId, pretty);
|
|
2089
2378
|
|
|
2090
2379
|
// --explain: dry-run that emits the preconditions + artifacts + indicators
|
|
2091
2380
|
// + signal keys the agent would need to supply, WITHOUT running detect/
|
|
@@ -2138,20 +2427,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2138
2427
|
// v0.11.1: auto-detect piped stdin. If no --evidence flag and stdin is a
|
|
2139
2428
|
// pipe, assume `--evidence -`. Operators forgetting the flag previously
|
|
2140
2429
|
// got a confusing precondition halt; now the common case "just works."
|
|
2141
|
-
//
|
|
2142
|
-
//
|
|
2143
|
-
//
|
|
2144
|
-
//
|
|
2145
|
-
//
|
|
2146
|
-
//
|
|
2147
|
-
//
|
|
2148
|
-
//
|
|
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).
|
|
2430
|
+
// Use the fstat-probing hasReadableStdin() helper. A raw `!isTTY` check
|
|
2431
|
+
// fires when isTTY is undefined (test harnesses with wrapped duplexers —
|
|
2432
|
+
// Mocha/Jest, Docker stdin-passthrough — leave isTTY === undefined but
|
|
2433
|
+
// never write any bytes), which causes readFileSync(0) to block waiting
|
|
2434
|
+
// on an EOF that never arrives. hasReadableStdin() does an fstat() probe
|
|
2435
|
+
// first, then falls back to a strict isTTY===false check only on Windows
|
|
2436
|
+
// (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
|
|
2437
|
+
// isTTY === false for genuine piped input, so that path still works.
|
|
2155
2438
|
if (!args.evidence && hasReadableStdin()) {
|
|
2156
2439
|
args.evidence = "-";
|
|
2157
2440
|
}
|
|
@@ -2187,11 +2470,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2187
2470
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
2188
2471
|
if (args.vex) {
|
|
2189
2472
|
let vexDoc;
|
|
2190
|
-
//
|
|
2191
|
-
//
|
|
2192
|
-
//
|
|
2193
|
-
//
|
|
2194
|
-
// the event loop for minutes / OOM'd the process. 32 MiB is well beyond
|
|
2473
|
+
// Cap --vex file size at 32 MiB (binary mebibytes, i.e. 32 * 1024 * 1024
|
|
2474
|
+
// = 33,554,432 bytes), matching readEvidence()'s --evidence cap. Without
|
|
2475
|
+
// the cap, a multi-GB file (binary log, JSON bomb, or accident) blocks
|
|
2476
|
+
// the event loop for minutes / OOM's the process. 32 MiB is well beyond
|
|
2195
2477
|
// any legitimate VEX submission.
|
|
2196
2478
|
const MAX_VEX_BYTES = 32 * 1024 * 1024;
|
|
2197
2479
|
let vstat;
|
|
@@ -2200,7 +2482,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2200
2482
|
return emitError(`run: failed to stat --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2201
2483
|
}
|
|
2202
2484
|
if (vstat.size > MAX_VEX_BYTES) {
|
|
2203
|
-
//
|
|
2485
|
+
// Error message names the binary mebi convention explicitly so
|
|
2204
2486
|
// operators don't mistake the cap for 32 * 10^6 = 32,000,000 bytes (MB).
|
|
2205
2487
|
return emitError(
|
|
2206
2488
|
`run: --vex file too large: ${vstat.size} bytes exceeds 32 MiB limit (${MAX_VEX_BYTES.toLocaleString("en-US")} bytes). Reduce the document or split into multiple passes.`,
|
|
@@ -2209,14 +2491,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2209
2491
|
);
|
|
2210
2492
|
}
|
|
2211
2493
|
try {
|
|
2212
|
-
//
|
|
2213
|
-
//
|
|
2494
|
+
// BOM-tolerant read. Windows-tool-emitted CycloneDX commonly carries
|
|
2495
|
+
// UTF-8-BOM or UTF-16 LE/BE markers; the raw "utf8" decode in
|
|
2214
2496
|
// readFileSync chokes on the leading 0xFEFF.
|
|
2215
2497
|
vexDoc = readJsonFile(args.vex);
|
|
2216
2498
|
} catch (e) {
|
|
2217
2499
|
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
2218
2500
|
}
|
|
2219
|
-
//
|
|
2501
|
+
// Validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
|
|
2220
2502
|
// The runner tolerantly returns Set(0) for anything that's not CycloneDX
|
|
2221
2503
|
// or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
|
|
2222
2504
|
// advisory by mistake got ZERO filter applied and ZERO feedback. Now:
|
|
@@ -2234,15 +2516,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2234
2516
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
2235
2517
|
submission.signals = submission.signals || {};
|
|
2236
2518
|
submission.signals.vex_filter = [...vexSet];
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
//
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
// the CLI. The bundle-correctness tests only exercised the analyze()
|
|
2245
|
-
// direct-call path with vex_fixed pre-injected, hiding this regression.
|
|
2519
|
+
// vexFilterFromDoc attaches a `.fixed` Set as an own property on the
|
|
2520
|
+
// returned filter Set (CycloneDX `analysis.state: 'resolved'` + OpenVEX
|
|
2521
|
+
// `status: 'fixed'` populate it). Forward it through to
|
|
2522
|
+
// signals.vex_fixed so analyze() receives the fixed-disposition CVE
|
|
2523
|
+
// ids, `vex_status: 'fixed'` annotates matched_cves entries, and CSAF
|
|
2524
|
+
// product_status.fixed + OpenVEX status:'fixed' propagate into the
|
|
2525
|
+
// bundle.
|
|
2246
2526
|
submission.signals.vex_fixed = vexSet.fixed ? [...vexSet.fixed] : [];
|
|
2247
2527
|
} catch (e) {
|
|
2248
2528
|
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
@@ -2281,14 +2561,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2281
2561
|
// ack state by that name (`result.ack` is shorter + matches the CLI flag).
|
|
2282
2562
|
if (result && runOpts.operator) result.operator = runOpts.operator;
|
|
2283
2563
|
|
|
2284
|
-
//
|
|
2285
|
-
//
|
|
2564
|
+
// --ack consent only counts when a jurisdiction clock is actually at
|
|
2565
|
+
// stake — i.e. the run produced classification=detected (a real finding
|
|
2286
2566
|
// that may trigger NIS2 24h / DORA 4h / GDPR 72h obligations). On a
|
|
2287
|
-
// not-detected or inconclusive run, persisting the consent
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2290
|
-
//
|
|
2291
|
-
//
|
|
2567
|
+
// not-detected or inconclusive run, persisting the consent would record
|
|
2568
|
+
// operator acknowledgement of a clock that never started. Surface the
|
|
2569
|
+
// ack state in the run body either way so operators see what happened,
|
|
2570
|
+
// but only persist `operator_consent` into the attestation when
|
|
2571
|
+
// classification === detected.
|
|
2292
2572
|
const detectClassification = result && result.phases && result.phases.detect
|
|
2293
2573
|
? result.phases.detect.classification
|
|
2294
2574
|
: null;
|
|
@@ -2313,7 +2593,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2313
2593
|
directiveId: result.directive_id,
|
|
2314
2594
|
evidenceHash: result.evidence_hash,
|
|
2315
2595
|
operator: runOpts.operator,
|
|
2316
|
-
//
|
|
2596
|
+
// Gate consent persistence on classification=detected.
|
|
2317
2597
|
operatorConsent: consentApplies ? runOpts.operator_consent : null,
|
|
2318
2598
|
submission,
|
|
2319
2599
|
runOpts,
|
|
@@ -2322,31 +2602,34 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2322
2602
|
});
|
|
2323
2603
|
if (!persistResult.ok) {
|
|
2324
2604
|
// Session-id collision without --force-overwrite, OR --force-overwrite
|
|
2325
|
-
// lost the lockfile race
|
|
2326
|
-
//
|
|
2327
|
-
//
|
|
2328
|
-
//
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
2331
|
-
//
|
|
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.
|
|
2605
|
+
// lost the lockfile race, OR the filesystem refused the write (full
|
|
2606
|
+
// disk, quota, read-only). Three distinct exit-code classes:
|
|
2607
|
+
// 8 LOCK_CONTENTION — retry from the outside (transient)
|
|
2608
|
+
// 9 STORAGE_EXHAUSTED — disk/quota/RO — operator-side infra fix
|
|
2609
|
+
// 7 SESSION_ID_COLLISION — pass --force-overwrite or fresh id
|
|
2610
|
+
// Route through emitError() shape so the body goes to stderr and exit
|
|
2611
|
+
// codes propagate via the emit() contract.
|
|
2335
2612
|
const err = {
|
|
2336
2613
|
ok: false,
|
|
2337
2614
|
error: persistResult.error,
|
|
2338
2615
|
existing_attestation: persistResult.existingPath,
|
|
2339
|
-
hint:
|
|
2616
|
+
hint: persistResult.storage_exhausted
|
|
2617
|
+
? "Free disk space, lift quota, or remount the attestation root read-write; then retry."
|
|
2618
|
+
: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
2340
2619
|
verb: "run",
|
|
2341
2620
|
};
|
|
2342
2621
|
if (persistResult.lock_contention) {
|
|
2343
2622
|
err.lock_contention = true;
|
|
2344
|
-
err.exit_code =
|
|
2623
|
+
err.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
2345
2624
|
}
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2625
|
+
if (persistResult.storage_exhausted) {
|
|
2626
|
+
err.storage_exhausted = true;
|
|
2627
|
+
err.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
2349
2628
|
}
|
|
2629
|
+
emitError(persistResult.error, err, pretty);
|
|
2630
|
+
if (persistResult.lock_contention) process.exitCode = EXIT_CODES.LOCK_CONTENTION;
|
|
2631
|
+
else if (persistResult.storage_exhausted) process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
2632
|
+
else process.exitCode = EXIT_CODES.SESSION_ID_COLLISION;
|
|
2350
2633
|
return;
|
|
2351
2634
|
}
|
|
2352
2635
|
if (persistResult.prior_session_id) {
|
|
@@ -2360,14 +2643,13 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2360
2643
|
}
|
|
2361
2644
|
|
|
2362
2645
|
if (result && result.ok === false) {
|
|
2363
|
-
//
|
|
2364
|
-
//
|
|
2365
|
-
//
|
|
2366
|
-
//
|
|
2367
|
-
//
|
|
2368
|
-
// (ok:false bodies are framework signals when no CI gating is asked for).
|
|
2646
|
+
// Align preflight-halt exit code between `run --ci` and `ci`: both use
|
|
2647
|
+
// 4 (BLOCKED) when --ci is in effect so operators can wire one set of
|
|
2648
|
+
// exit-code expectations regardless of which verb they call. Without
|
|
2649
|
+
// --ci the legacy exit 1 is preserved (ok:false bodies are framework
|
|
2650
|
+
// signals when no CI gating is requested).
|
|
2369
2651
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
2370
|
-
process.exitCode = args.ci ?
|
|
2652
|
+
process.exitCode = args.ci ? EXIT_CODES.BLOCKED : EXIT_CODES.GENERIC_FAILURE;
|
|
2371
2653
|
return;
|
|
2372
2654
|
}
|
|
2373
2655
|
|
|
@@ -2388,11 +2670,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2388
2670
|
result.strict_preconditions_violated = warnIssues.map(i => ({
|
|
2389
2671
|
id: i.id, kind: i.kind, message: i.message || null, on_fail: i.on_fail || null,
|
|
2390
2672
|
}));
|
|
2391
|
-
process.stderr.write(`[exceptd run] --strict-preconditions: ${warnIssues.length} unverified/warn precondition(s) — exit
|
|
2673
|
+
process.stderr.write(`[exceptd run] --strict-preconditions: ${warnIssues.length} unverified/warn precondition(s) — exit ${EXIT_CODES.GENERIC_FAILURE}.\n`);
|
|
2392
2674
|
emit(result, pretty);
|
|
2393
2675
|
// v0.11.11: exitCode + return so emit()'s stdout flushes (process.exit
|
|
2394
2676
|
// can truncate buffered async stdout writes when piped).
|
|
2395
|
-
process.exitCode =
|
|
2677
|
+
process.exitCode = EXIT_CODES.GENERIC_FAILURE;
|
|
2396
2678
|
return;
|
|
2397
2679
|
}
|
|
2398
2680
|
}
|
|
@@ -2430,9 +2712,9 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2430
2712
|
const refs = startedClocks
|
|
2431
2713
|
.map(n => `${n.obligation_ref || n.jurisdiction || "?"}@${n.clock_started_at}`)
|
|
2432
2714
|
.join("; ");
|
|
2433
|
-
process.stderr.write(`[exceptd run --block-on-jurisdiction-clock] CLOCK_STARTED: ${startedClocks.length} jurisdiction clock(s) running and unacked: ${refs}. Exit
|
|
2715
|
+
process.stderr.write(`[exceptd run --block-on-jurisdiction-clock] CLOCK_STARTED: ${startedClocks.length} jurisdiction clock(s) running and unacked: ${refs}. Exit ${EXIT_CODES.JURISDICTION_CLOCK_STARTED}.\n`);
|
|
2434
2716
|
emit(result, pretty);
|
|
2435
|
-
process.exitCode =
|
|
2717
|
+
process.exitCode = EXIT_CODES.JURISDICTION_CLOCK_STARTED;
|
|
2436
2718
|
return;
|
|
2437
2719
|
}
|
|
2438
2720
|
}
|
|
@@ -2469,12 +2751,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2469
2751
|
// under piped consumers (CI runners, jq, test harnesses).
|
|
2470
2752
|
if (classification === "detected") {
|
|
2471
2753
|
process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
|
|
2472
|
-
process.exitCode =
|
|
2754
|
+
process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
|
|
2473
2755
|
return;
|
|
2474
2756
|
}
|
|
2475
2757
|
if (classification === "inconclusive" && escalate) {
|
|
2476
2758
|
process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
|
|
2477
|
-
process.exitCode =
|
|
2759
|
+
process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
|
|
2478
2760
|
return;
|
|
2479
2761
|
}
|
|
2480
2762
|
if (classification === "inconclusive") {
|
|
@@ -2573,9 +2855,10 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2573
2855
|
const top = rwep?.threshold?.escalate ?? "n/a";
|
|
2574
2856
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
2575
2857
|
lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
|
|
2576
|
-
// F11: surface --diff-from-latest verdict in the human renderer
|
|
2577
|
-
// operators
|
|
2578
|
-
//
|
|
2858
|
+
// F11: surface --diff-from-latest verdict in the human renderer so
|
|
2859
|
+
// operators see whether the run drifted from the previous attestation
|
|
2860
|
+
// without adding --json. One summary line follows the classification.
|
|
2861
|
+
// Marker text is grep-matched by tests/audit-i-l-m-fixes.test.js F11.
|
|
2579
2862
|
// - unchanged: same evidence_hash as prior → reassuring single line.
|
|
2580
2863
|
// - drifted: evidence differs → loud DRIFTED marker.
|
|
2581
2864
|
// - no_prior_attestation_for_playbook: no line — don't clutter the
|
|
@@ -2653,7 +2936,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2653
2936
|
* inconclusive findings + visibility gaps) when no --evidence is given.
|
|
2654
2937
|
*/
|
|
2655
2938
|
/**
|
|
2656
|
-
*
|
|
2939
|
+
* Collapse per-playbook notification_actions into a deduped rollup.
|
|
2657
2940
|
* Multi-playbook runs frequently surface the same jurisdiction clock from
|
|
2658
2941
|
* 5-10 contributing playbooks (every EU-touching playbook starts a fresh
|
|
2659
2942
|
* NIS2 Art.23 24h clock). Operators were drafting one notification per
|
|
@@ -2688,12 +2971,11 @@ function buildJurisdictionClockRollup(results) {
|
|
|
2688
2971
|
existing.deadline = n.deadline;
|
|
2689
2972
|
}
|
|
2690
2973
|
} else {
|
|
2691
|
-
//
|
|
2692
|
-
// entry promised) AND retain `obligation_ref` as a kept-name alias
|
|
2974
|
+
// Emit `obligation` and retain `obligation_ref` as a kept-name alias
|
|
2693
2975
|
// for any consumer that already parses the older shape. The dedupe
|
|
2694
2976
|
// key still keys on n.obligation_ref since that's the field
|
|
2695
2977
|
// notification-action stubs carry; the rollup body just exposes
|
|
2696
|
-
// both names
|
|
2978
|
+
// both names.
|
|
2697
2979
|
const obligation = n.obligation_ref || null;
|
|
2698
2980
|
m.set(key, {
|
|
2699
2981
|
jurisdiction: n.jurisdiction || null,
|
|
@@ -2740,20 +3022,30 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2740
3022
|
// symlink/junction inside the dir, but the filter is cheap.
|
|
2741
3023
|
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
2742
3024
|
const pbId = f.replace(/\.json$/, "");
|
|
2743
|
-
|
|
2744
|
-
|
|
3025
|
+
// Reuse the shared playbook-id validator so the --evidence-dir entry
|
|
3026
|
+
// filter agrees with the runtime playbook-id allowlist. Previously
|
|
3027
|
+
// accepted dots / underscores / uppercase that no real playbook id
|
|
3028
|
+
// uses, which would silently absorb a typo'd filename as a "valid"
|
|
3029
|
+
// entry that loadPlaybook then refused mid-loop.
|
|
3030
|
+
const pbCheck = validateIdComponent(pbId, "playbook");
|
|
3031
|
+
if (!pbCheck.ok) {
|
|
3032
|
+
return emitError(
|
|
3033
|
+
`run: --evidence-dir entry ${JSON.stringify(f)} has invalid playbook-id segment (${pbCheck.reason}).`,
|
|
3034
|
+
{ entry: f, expected_shape: "<playbook-id>.json (lowercase, starts with letter, no dots)" },
|
|
3035
|
+
pretty
|
|
3036
|
+
);
|
|
2745
3037
|
}
|
|
2746
3038
|
const entryPath = path.resolve(path.join(resolvedDir, f));
|
|
2747
3039
|
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
2748
3040
|
return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
|
|
2749
3041
|
}
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
//
|
|
2753
|
-
//
|
|
2754
|
-
//
|
|
2755
|
-
//
|
|
2756
|
-
//
|
|
3042
|
+
// The path.resolve check above only catches `..` traversal in the
|
|
3043
|
+
// joined path; fs.readFileSync(entryPath) still follows symlinks, so
|
|
3044
|
+
// a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
|
|
3045
|
+
// slurp the target. lstat is symlink-aware (it does NOT follow);
|
|
3046
|
+
// refuse anything that's not a regular file. Defense in depth on top
|
|
3047
|
+
// of the readdir filter — a junction (Windows) or bind-mount can
|
|
3048
|
+
// shape-shift in between filter and read.
|
|
2757
3049
|
let lst;
|
|
2758
3050
|
try { lst = fs.lstatSync(entryPath); }
|
|
2759
3051
|
catch (e) {
|
|
@@ -2765,9 +3057,9 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2765
3057
|
if (!lst.isFile()) {
|
|
2766
3058
|
return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
|
|
2767
3059
|
}
|
|
2768
|
-
//
|
|
3060
|
+
// Windows directory junctions are reparse-point dirs that
|
|
2769
3061
|
// `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
|
|
2770
|
-
// ordinary directories)
|
|
3062
|
+
// ordinary directories), bypassing the symlink refusal above. Use
|
|
2771
3063
|
// realpathSync to resolve the entry and confirm it still lives under
|
|
2772
3064
|
// the resolved evidence-dir — the realpath approach is portable
|
|
2773
3065
|
// (catches POSIX symlinks too, defense in depth) and works regardless
|
|
@@ -2784,10 +3076,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2784
3076
|
pretty
|
|
2785
3077
|
);
|
|
2786
3078
|
}
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
3079
|
+
// Hardlink defense in depth: no clean cross-platform refusal exists —
|
|
3080
|
+
// hardlinks are indistinguishable from regular files at the inode
|
|
3081
|
+
// level. Surface a stderr warning when nlink > 1 so the operator is
|
|
3082
|
+
// aware a second name may point at the same file. Not a refusal —
|
|
2791
3083
|
// legitimate use cases (atomic rename, package-manager dedup) produce
|
|
2792
3084
|
// nlink > 1 without malicious intent.
|
|
2793
3085
|
if (lst.nlink > 1) {
|
|
@@ -2803,10 +3095,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2803
3095
|
|
|
2804
3096
|
const results = [];
|
|
2805
3097
|
for (const id of ids) {
|
|
3098
|
+
// Defense-in-depth: ids come from listPlaybooks() / filterPlaybooksByScope
|
|
3099
|
+
// (which read trusted catalog data), but threading every id through
|
|
3100
|
+
// validateIdComponent('playbook') means a corrupt catalog cannot
|
|
3101
|
+
// path-traverse via this loop either.
|
|
3102
|
+
const r = validateIdComponent(id, "playbook");
|
|
3103
|
+
if (!r.ok) {
|
|
3104
|
+
results.push({ playbook_id: id, ok: false, error: `invalid playbook id (${r.reason})` });
|
|
3105
|
+
continue;
|
|
3106
|
+
}
|
|
2806
3107
|
const pb = runner.loadPlaybook(id);
|
|
2807
3108
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2808
3109
|
if (!directiveId) {
|
|
2809
|
-
results.push({ playbook_id: id, ok: false, error:
|
|
3110
|
+
results.push({ playbook_id: id, ok: false, error: `playbook ${id} has no directives` });
|
|
2810
3111
|
continue;
|
|
2811
3112
|
}
|
|
2812
3113
|
const submission = bundle[id] || {};
|
|
@@ -2815,16 +3116,13 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2815
3116
|
|
|
2816
3117
|
const result = runner.run(id, directiveId, submission, perRunOpts);
|
|
2817
3118
|
|
|
2818
|
-
//
|
|
2819
|
-
//
|
|
2820
|
-
//
|
|
2821
|
-
//
|
|
2822
|
-
// playbook
|
|
2823
|
-
//
|
|
2824
|
-
//
|
|
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.
|
|
3119
|
+
// Per-playbook --ack gating: consent only counts when a jurisdiction
|
|
3120
|
+
// clock is actually at stake on THIS playbook's verdict — i.e. its
|
|
3121
|
+
// detect.classification === 'detected'. Without this gate, a single
|
|
3122
|
+
// --ack on a run-all invocation would persist explicit consent into
|
|
3123
|
+
// every playbook's attestation regardless of whether that playbook's
|
|
3124
|
+
// run started a clock. The `ack_skipped_reason` surface mirrors cmdRun
|
|
3125
|
+
// so consumers see exactly which playbooks consumed the ack.
|
|
2828
3126
|
const perDetectClassification = result && result.phases && result.phases.detect
|
|
2829
3127
|
? result.phases.detect.classification
|
|
2830
3128
|
: null;
|
|
@@ -2849,8 +3147,8 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2849
3147
|
directiveId,
|
|
2850
3148
|
evidenceHash: result.evidence_hash,
|
|
2851
3149
|
operator: perRunOpts.operator,
|
|
2852
|
-
//
|
|
2853
|
-
//
|
|
3150
|
+
// Gate consent persistence on this playbook's classification, not
|
|
3151
|
+
// on the aggregate run's --ack presence.
|
|
2854
3152
|
operatorConsent: perConsentApplies ? perRunOpts.operator_consent : null,
|
|
2855
3153
|
submission,
|
|
2856
3154
|
runOpts: perRunOpts,
|
|
@@ -2860,8 +3158,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2860
3158
|
if (!persisted.ok) {
|
|
2861
3159
|
// Multi-run collision: don't abort the whole bundle; surface in the
|
|
2862
3160
|
// per-playbook result so the operator can see exactly which
|
|
2863
|
-
// playbook's attestation refused to overwrite.
|
|
3161
|
+
// playbook's attestation refused to overwrite. Propagate
|
|
3162
|
+
// lock_contention / storage_exhausted / exit_code so the aggregate
|
|
3163
|
+
// exit-code gate below picks the right top-level code (8 / 9 /
|
|
3164
|
+
// 7 / 1) instead of collapsing every persist failure to 1.
|
|
2864
3165
|
result.attestation_persist = { ok: false, error: persisted.error };
|
|
3166
|
+
if (persisted.lock_contention) {
|
|
3167
|
+
result.attestation_persist.lock_contention = true;
|
|
3168
|
+
result.attestation_persist.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
3169
|
+
}
|
|
3170
|
+
if (persisted.storage_exhausted) {
|
|
3171
|
+
result.attestation_persist.storage_exhausted = true;
|
|
3172
|
+
result.attestation_persist.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
3173
|
+
}
|
|
2865
3174
|
} else if (persisted.prior_session_id) {
|
|
2866
3175
|
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
2867
3176
|
}
|
|
@@ -2869,12 +3178,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2869
3178
|
results.push(result);
|
|
2870
3179
|
}
|
|
2871
3180
|
|
|
2872
|
-
//
|
|
2873
|
-
// results into a single rollup.
|
|
2874
|
-
// contributors of "EU NIS2 Art.23 24h"
|
|
2875
|
-
// operators
|
|
2876
|
-
// playbook entries are preserved on individual results; this rollup
|
|
2877
|
-
// additive — keyed on (jurisdiction, regulation, obligation_ref,
|
|
3181
|
+
// Dedupe jurisdiction-clock notification actions across all playbook
|
|
3182
|
+
// results into a single rollup. Without this, a 13-playbook multi-run
|
|
3183
|
+
// with 8 contributors of "EU NIS2 Art.23 24h" produces 8 separate
|
|
3184
|
+
// entries and operators draft 8 NIS2 notifications when one suffices.
|
|
3185
|
+
// Per-playbook entries are preserved on individual results; this rollup
|
|
3186
|
+
// is additive — keyed on (jurisdiction, regulation, obligation_ref,
|
|
2878
3187
|
// window_hours) — with a triggered_by_playbooks[] list so operators see
|
|
2879
3188
|
// which playbooks contributed.
|
|
2880
3189
|
const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
|
|
@@ -2900,27 +3209,33 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2900
3209
|
// the body but exit code stayed 0 — CI gates couldn't distinguish "ran
|
|
2901
3210
|
// clean" from "blocked." v0.12.8: use exitCode (not process.exit()) so
|
|
2902
3211
|
// the aggregate JSON emitted above is allowed to fully drain.
|
|
3212
|
+
//
|
|
3213
|
+
// Aggregate exit-code precedence: LOCK_CONTENTION > STORAGE_EXHAUSTED >
|
|
3214
|
+
// BLOCKED. Lock contention is transient (retry-from-outside fixes it);
|
|
3215
|
+
// storage exhaustion is an infra event requiring operator action;
|
|
3216
|
+
// ok:false in a per-playbook result is the BLOCKED case. Surfacing the
|
|
3217
|
+
// most-specific code first means a CI gate can branch on the right
|
|
3218
|
+
// remediation without parsing the body.
|
|
3219
|
+
const anyLockBusy = results.some(r => r.attestation_persist && r.attestation_persist.lock_contention === true);
|
|
3220
|
+
const anyStorageExhausted = results.some(r => r.attestation_persist && r.attestation_persist.storage_exhausted === true);
|
|
2903
3221
|
const anyBlocked = results.some(r => r.ok === false);
|
|
2904
|
-
if (
|
|
3222
|
+
if (anyLockBusy) { process.exitCode = EXIT_CODES.LOCK_CONTENTION; return; }
|
|
3223
|
+
if (anyStorageExhausted) { process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED; return; }
|
|
3224
|
+
if (anyBlocked) { process.exitCode = EXIT_CODES.GENERIC_FAILURE; return; }
|
|
2905
3225
|
}
|
|
2906
3226
|
|
|
2907
3227
|
function cmdIngest(runner, args, runOpts, pretty) {
|
|
2908
3228
|
// `ingest` matches the AGENTS.md ingest contract. The submission JSON may
|
|
2909
3229
|
// carry playbook_id + directive_id; --domain/--directive flags override.
|
|
2910
3230
|
let submission = {};
|
|
2911
|
-
//
|
|
2912
|
-
// `echo '{...}' | exceptd ingest`
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2918
|
-
//
|
|
2919
|
-
// detects piped stdin") was a no-op on Windows pre-fix.
|
|
2920
|
-
//
|
|
2921
|
-
// EE P1-7: route through hasReadableStdin() — see cmdRun for rationale.
|
|
2922
|
-
// Wrapped-stdin test harnesses (Mocha/Jest, Docker stdin-passthrough)
|
|
2923
|
-
// would otherwise block here forever on the readFileSync(0) call.
|
|
3231
|
+
// Auto-detect piped stdin (parity with cmdRun) so
|
|
3232
|
+
// `echo '{...}' | exceptd ingest` reads the routing JSON instead of
|
|
3233
|
+
// failing with "no playbook resolved" because args.evidence stays
|
|
3234
|
+
// undefined.
|
|
3235
|
+
// Route stdin auto-detection through hasReadableStdin() (see cmdRun for
|
|
3236
|
+
// rationale). Wrapped-stdin test harnesses (Mocha/Jest, Docker
|
|
3237
|
+
// stdin-passthrough) would otherwise block here forever on the
|
|
3238
|
+
// readFileSync(0) call when isTTY === undefined.
|
|
2924
3239
|
if (!args.evidence && hasReadableStdin()) {
|
|
2925
3240
|
args.evidence = "-";
|
|
2926
3241
|
}
|
|
@@ -2933,11 +3248,12 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2933
3248
|
}
|
|
2934
3249
|
const playbookId = args.domain || submission.playbook_id || submission.domain;
|
|
2935
3250
|
if (!playbookId) return emitError("ingest: no playbook resolved — pass --domain <id> or include playbook_id in evidence JSON.", null, pretty);
|
|
3251
|
+
if (refuseInvalidPlaybookId("ingest", playbookId, pretty)) return;
|
|
2936
3252
|
const pb = runner.loadPlaybook(playbookId);
|
|
2937
3253
|
const directiveId = args.directive
|
|
2938
3254
|
|| submission.directive_id
|
|
2939
3255
|
|| (pb.directives[0] && pb.directives[0].id);
|
|
2940
|
-
if (!directiveId) return
|
|
3256
|
+
if (!directiveId) return refuseNoDirectives("ingest", playbookId, pretty);
|
|
2941
3257
|
|
|
2942
3258
|
// Strip the routing keys so the runner only sees the contract shape it expects.
|
|
2943
3259
|
const cleanedSubmission = {
|
|
@@ -2984,17 +3300,24 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2984
3300
|
filename: "attestation.json",
|
|
2985
3301
|
});
|
|
2986
3302
|
if (!persisted.ok) {
|
|
2987
|
-
//
|
|
2988
|
-
//
|
|
2989
|
-
//
|
|
3303
|
+
// Route every persist-failure shape through emitError so the
|
|
3304
|
+
// emit() ok:false → exitCode contract applies uniformly. Three
|
|
3305
|
+
// exit classes: LOCK_CONTENTION (transient), STORAGE_EXHAUSTED
|
|
3306
|
+
// (infra), SESSION_ID_COLLISION (operator decision).
|
|
2990
3307
|
const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
|
|
2991
3308
|
if (persisted.lock_contention) {
|
|
2992
3309
|
ctx.lock_contention = true;
|
|
2993
|
-
ctx.exit_code =
|
|
2994
|
-
process.stderr.write(JSON.stringify({ ok: false, error: persisted.error, ...ctx }) + "\n");
|
|
2995
|
-
return;
|
|
3310
|
+
ctx.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
2996
3311
|
}
|
|
2997
|
-
|
|
3312
|
+
if (persisted.storage_exhausted) {
|
|
3313
|
+
ctx.storage_exhausted = true;
|
|
3314
|
+
ctx.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
3315
|
+
}
|
|
3316
|
+
emitError(persisted.error, ctx, pretty);
|
|
3317
|
+
if (persisted.lock_contention) process.exitCode = EXIT_CODES.LOCK_CONTENTION;
|
|
3318
|
+
else if (persisted.storage_exhausted) process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
3319
|
+
else process.exitCode = EXIT_CODES.SESSION_ID_COLLISION;
|
|
3320
|
+
return;
|
|
2998
3321
|
}
|
|
2999
3322
|
if (persisted.prior_session_id) {
|
|
3000
3323
|
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
@@ -3141,21 +3464,21 @@ function persistAttestation(args) {
|
|
|
3141
3464
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3142
3465
|
};
|
|
3143
3466
|
}
|
|
3144
|
-
//
|
|
3145
|
-
//
|
|
3146
|
-
//
|
|
3147
|
-
//
|
|
3148
|
-
//
|
|
3149
|
-
//
|
|
3150
|
-
// DD P1-2: MAX_RETRIES capped at 10
|
|
3151
|
-
//
|
|
3152
|
-
//
|
|
3153
|
-
//
|
|
3154
|
-
//
|
|
3155
|
-
//
|
|
3156
|
-
//
|
|
3157
|
-
//
|
|
3158
|
-
//
|
|
3467
|
+
// Serialize the read-prior + write-new sequence behind a lockfile so
|
|
3468
|
+
// concurrent --force-overwrite invocations against the same session-id
|
|
3469
|
+
// slot do not degrade to last-write-wins. Pattern matches
|
|
3470
|
+
// withCatalogLock + withIndexLock: O_EXCL 'wx' on a sibling .lock file
|
|
3471
|
+
// with bounded retry, PID-liveness check on contention, mtime fallback
|
|
3472
|
+
// for orphaned lockfiles.
|
|
3473
|
+
// DD P1-2: MAX_RETRIES is capped at 10. persistAttestation is sync and
|
|
3474
|
+
// called from sync callers, so the wait loop must busy-spin (no
|
|
3475
|
+
// event-loop yield available). A larger bound would peg the CPU and
|
|
3476
|
+
// freeze the event loop for multiple seconds under attestation
|
|
3477
|
+
// contention. Capping at 10 bounds the freeze at ~1-2s; beyond that
|
|
3478
|
+
// callers receive the LOCK_CONTENTION sentinel on the result object
|
|
3479
|
+
// and can retry from the outside without holding the CPU. Async
|
|
3480
|
+
// refactor of persistAttestation + every caller is a v0.13.0
|
|
3481
|
+
// candidate.
|
|
3159
3482
|
const lockPath = filePath + ".lock";
|
|
3160
3483
|
const MAX_RETRIES = 10;
|
|
3161
3484
|
const STALE_LOCK_MS = 30_000;
|
|
@@ -3166,6 +3489,22 @@ function persistAttestation(args) {
|
|
|
3166
3489
|
acquired = true;
|
|
3167
3490
|
break;
|
|
3168
3491
|
} catch (lockErr) {
|
|
3492
|
+
// Distinguish lockfile contention (EEXIST/EPERM = another holder)
|
|
3493
|
+
// from storage-exhaustion classes (ENOSPC = disk full,
|
|
3494
|
+
// EROFS = read-only fs, EDQUOT = quota exceeded). The latter are
|
|
3495
|
+
// infra-level failures that no amount of retry-spin will resolve;
|
|
3496
|
+
// surface them with a distinct exit code (STORAGE_EXHAUSTED = 9)
|
|
3497
|
+
// so operator runbooks can branch on "free disk" vs "retry".
|
|
3498
|
+
if (lockErr.code === "ENOSPC" || lockErr.code === "EROFS" || lockErr.code === "EDQUOT") {
|
|
3499
|
+
process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
3500
|
+
return {
|
|
3501
|
+
ok: false,
|
|
3502
|
+
error: `STORAGE_EXHAUSTED: ${lockErr.message}`,
|
|
3503
|
+
existingPath: path.relative(process.cwd(), filePath),
|
|
3504
|
+
storage_exhausted: true,
|
|
3505
|
+
exit_code: EXIT_CODES.STORAGE_EXHAUSTED,
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3169
3508
|
if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
|
|
3170
3509
|
let reclaimed = false;
|
|
3171
3510
|
try {
|
|
@@ -3194,28 +3533,25 @@ function persistAttestation(args) {
|
|
|
3194
3533
|
}
|
|
3195
3534
|
}
|
|
3196
3535
|
if (!acquired) {
|
|
3197
|
-
//
|
|
3198
|
-
// genuine lock-busy condition (retry-from-outside is
|
|
3199
|
-
// from a hard failure (write error, permission
|
|
3200
|
-
// spin budget
|
|
3201
|
-
//
|
|
3536
|
+
// Surface lock_contention as a distinct sentinel so callers can
|
|
3537
|
+
// distinguish a genuine lock-busy condition (retry-from-outside is
|
|
3538
|
+
// the right move) from a hard failure (write error, permission
|
|
3539
|
+
// denial). The sync spin budget is bounded above so this return
|
|
3540
|
+
// fires after ~1-2s of contention.
|
|
3202
3541
|
//
|
|
3203
|
-
//
|
|
3204
|
-
//
|
|
3205
|
-
// the
|
|
3206
|
-
//
|
|
3207
|
-
//
|
|
3208
|
-
//
|
|
3209
|
-
|
|
3210
|
-
// exclusively for LOCK_CONTENTION (attestation persist); see the
|
|
3211
|
-
// exit-code table in printGlobalHelp().
|
|
3212
|
-
process.exitCode = 8;
|
|
3542
|
+
// emit() auto-maps any ok:false body to process.exitCode = 1 (only
|
|
3543
|
+
// when the current value is still 0). Pin process.exitCode = 8 HERE
|
|
3544
|
+
// before the caller hands the body to emit(); emit() preserves the
|
|
3545
|
+
// already-non-zero value. Exit code 8 is reserved exclusively for
|
|
3546
|
+
// LOCK_CONTENTION (attestation persist); see the exit-code table in
|
|
3547
|
+
// printGlobalHelp().
|
|
3548
|
+
process.exitCode = EXIT_CODES.LOCK_CONTENTION;
|
|
3213
3549
|
return {
|
|
3214
3550
|
ok: false,
|
|
3215
3551
|
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.`,
|
|
3216
3552
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3217
3553
|
lock_contention: true,
|
|
3218
|
-
exit_code:
|
|
3554
|
+
exit_code: EXIT_CODES.LOCK_CONTENTION,
|
|
3219
3555
|
};
|
|
3220
3556
|
}
|
|
3221
3557
|
try {
|
|
@@ -3238,6 +3574,19 @@ function persistAttestation(args) {
|
|
|
3238
3574
|
}
|
|
3239
3575
|
}
|
|
3240
3576
|
} catch (e) {
|
|
3577
|
+
// ENOSPC / EROFS / EDQUOT are storage-exhaustion classes — surface
|
|
3578
|
+
// them with a distinct sentinel + exit code so callers route them
|
|
3579
|
+
// through a different remediation path than generic write errors.
|
|
3580
|
+
if (e && (e.code === "ENOSPC" || e.code === "EROFS" || e.code === "EDQUOT")) {
|
|
3581
|
+
process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
3582
|
+
return {
|
|
3583
|
+
ok: false,
|
|
3584
|
+
error: `STORAGE_EXHAUSTED: ${e.message}`,
|
|
3585
|
+
existingPath: null,
|
|
3586
|
+
storage_exhausted: true,
|
|
3587
|
+
exit_code: EXIT_CODES.STORAGE_EXHAUSTED,
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3241
3590
|
return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
|
|
3242
3591
|
}
|
|
3243
3592
|
}
|
|
@@ -3253,7 +3602,7 @@ function persistAttestation(args) {
|
|
|
3253
3602
|
* from "the .sig file was deleted by an attacker."
|
|
3254
3603
|
*/
|
|
3255
3604
|
/**
|
|
3256
|
-
*
|
|
3605
|
+
* Byte-stability normalize() for the attestation pipeline.
|
|
3257
3606
|
* Strips a leading UTF-8 BOM and collapses CRLF → LF. Mirrors the
|
|
3258
3607
|
* normalize() implementations in lib/sign.js, lib/verify.js,
|
|
3259
3608
|
* lib/refresh-network.js, and scripts/verify-shipped-tarball.js. Five
|
|
@@ -3279,13 +3628,13 @@ function maybeSignAttestation(filePath) {
|
|
|
3279
3628
|
// (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
|
|
3280
3629
|
// verifier doesn't trust.
|
|
3281
3630
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3282
|
-
//
|
|
3283
|
-
//
|
|
3631
|
+
// Normalize attestation bytes before sign — strip leading UTF-8 BOM +
|
|
3632
|
+
// collapse CRLF to LF. Mirrors lib/sign.js / lib/verify.js /
|
|
3284
3633
|
// lib/refresh-network.js / scripts/verify-shipped-tarball.js. The
|
|
3285
3634
|
// attestation file lives on disk under .exceptd/ and can pick up CRLF
|
|
3286
3635
|
// through git-attribute / editor round-trips on Windows; without
|
|
3287
3636
|
// normalization the sign/verify pair diverges on the same logical content.
|
|
3288
|
-
// The byte-stability contract
|
|
3637
|
+
// The byte-stability contract spans five sites; tests/normalize-contract
|
|
3289
3638
|
// .test.js enforces byte-identical output across all of them.
|
|
3290
3639
|
const rawContent = fs.readFileSync(filePath, "utf8");
|
|
3291
3640
|
const content = normalizeAttestationBytes(rawContent);
|
|
@@ -3309,16 +3658,15 @@ function maybeSignAttestation(filePath) {
|
|
|
3309
3658
|
key: privateKey,
|
|
3310
3659
|
dsaEncoding: "ieee-p1363",
|
|
3311
3660
|
});
|
|
3312
|
-
//
|
|
3313
|
-
//
|
|
3314
|
-
//
|
|
3315
|
-
//
|
|
3316
|
-
//
|
|
3317
|
-
//
|
|
3318
|
-
//
|
|
3319
|
-
// mtime; the attestation file's `captured_at` field is
|
|
3320
|
-
// signed.
|
|
3321
|
-
// Ed25519 signature payload, and an explanatory note.
|
|
3661
|
+
// The sidecar's Ed25519 signature covers ONLY the attestation file
|
|
3662
|
+
// bytes. Fields that travel inside the .sig but are NOT in the signed
|
|
3663
|
+
// message are replay-rewrite trivial: an attacker who can write the
|
|
3664
|
+
// directory can mutate them without invalidating the signature. The
|
|
3665
|
+
// sidecar therefore carries only the algorithm tag, the Ed25519
|
|
3666
|
+
// signature payload, and an explanatory note — no `signed_at`,
|
|
3667
|
+
// `signs_path`, or `signs_sha256`. Operators reading freshness use
|
|
3668
|
+
// filesystem mtime; the attestation file's `captured_at` field is
|
|
3669
|
+
// what's signed.
|
|
3322
3670
|
fs.writeFileSync(sigPath, JSON.stringify({
|
|
3323
3671
|
algorithm: "Ed25519",
|
|
3324
3672
|
signature_base64: sig.toString("base64"),
|
|
@@ -3350,9 +3698,13 @@ function maybeSignAttestation(filePath) {
|
|
|
3350
3698
|
* Validation regex + root-confinement check matches persistAttestation.
|
|
3351
3699
|
*/
|
|
3352
3700
|
function validateSessionIdForRead(sessionId) {
|
|
3353
|
-
|
|
3701
|
+
// Route through validateIdComponent('session') so the regex + all-dots
|
|
3702
|
+
// refusal stay aligned with the write-path validator in
|
|
3703
|
+
// persistAttestation. Single source of truth in lib/id-validation.js.
|
|
3704
|
+
const r = validateIdComponent(sessionId, "session");
|
|
3705
|
+
if (!r.ok) {
|
|
3354
3706
|
throw new Error(
|
|
3355
|
-
`Invalid session-id: ${JSON.stringify(sessionId).slice(0, 80)
|
|
3707
|
+
`Invalid session-id: ${typeof sessionId === "string" ? JSON.stringify(sessionId).slice(0, 80) : typeof sessionId}. ${r.reason}.`
|
|
3356
3708
|
);
|
|
3357
3709
|
}
|
|
3358
3710
|
return sessionId;
|
|
@@ -3411,6 +3763,12 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
3411
3763
|
try {
|
|
3412
3764
|
const p = path.join(sdir, f);
|
|
3413
3765
|
const j = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
3766
|
+
// Replay records (kind: 'replay') are an audit trail of force-replay
|
|
3767
|
+
// overrides, not a separate attestation. They have no captured_at /
|
|
3768
|
+
// evidence_hash and must not surface as candidates for --latest.
|
|
3769
|
+
// Gate on the parsed kind so a renamed file cannot smuggle a replay
|
|
3770
|
+
// record into the listing.
|
|
3771
|
+
if (j && j.kind === "replay") continue;
|
|
3414
3772
|
if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
|
|
3415
3773
|
if (opts.since && (j.captured_at || "") < opts.since) continue;
|
|
3416
3774
|
if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
|
|
@@ -3421,14 +3779,14 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
3421
3779
|
}
|
|
3422
3780
|
|
|
3423
3781
|
/**
|
|
3424
|
-
*
|
|
3425
|
-
*
|
|
3782
|
+
* Factored Ed25519-sidecar verification used by both `attest verify` and
|
|
3783
|
+
* `reattest`. Returns { file, signed, verified, reason } for a given
|
|
3426
3784
|
* attestation file path.
|
|
3427
3785
|
*
|
|
3428
|
-
*
|
|
3429
|
-
*
|
|
3430
|
-
*
|
|
3431
|
-
*
|
|
3786
|
+
* Callers must check `signed && verified` before consuming the
|
|
3787
|
+
* attestation. cmdReattest refuses to replay on verify-fail unless
|
|
3788
|
+
* --force-replay is set, so a tampered attestation cannot silently feed
|
|
3789
|
+
* forged input into the drift verdict.
|
|
3432
3790
|
*/
|
|
3433
3791
|
function verifyAttestationSidecar(attFile) {
|
|
3434
3792
|
const crypto = require("crypto");
|
|
@@ -3488,15 +3846,15 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3488
3846
|
}
|
|
3489
3847
|
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3490
3848
|
}
|
|
3491
|
-
//
|
|
3492
|
-
//
|
|
3493
|
-
//
|
|
3494
|
-
//
|
|
3495
|
-
//
|
|
3496
|
-
//
|
|
3497
|
-
//
|
|
3498
|
-
//
|
|
3499
|
-
//
|
|
3849
|
+
// Strict algorithm check. A branch on `=== "unsigned"` alone would let
|
|
3850
|
+
// null, undefined, "RSA-PSS", arrays, etc. fall through to crypto.verify
|
|
3851
|
+
// with default Ed25519 args — which can either succeed against
|
|
3852
|
+
// wrong-algorithm signature bytes accidentally (an attacker who can
|
|
3853
|
+
// write the sidecar replays an existing Ed25519 signature under a
|
|
3854
|
+
// downgrade-bait algorithm tag) or throw a generic verify error.
|
|
3855
|
+
// Refuse anything that isn't exactly "Ed25519" or "unsigned" with a
|
|
3856
|
+
// structured tamper class so callers can route the refusal through the
|
|
3857
|
+
// same exit-6 path as other tamper events.
|
|
3500
3858
|
if (sigDoc.algorithm !== "Ed25519") {
|
|
3501
3859
|
return {
|
|
3502
3860
|
file: attFile,
|
|
@@ -3512,9 +3870,9 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3512
3870
|
let content;
|
|
3513
3871
|
try {
|
|
3514
3872
|
const raw = fs.readFileSync(attFile, "utf8");
|
|
3515
|
-
//
|
|
3516
|
-
//
|
|
3517
|
-
//
|
|
3873
|
+
// Apply the same normalize() used by the signer so the verify path is
|
|
3874
|
+
// byte-stable across CRLF / BOM churn (Windows checkout with
|
|
3875
|
+
// core.autocrlf=true, editor round-trips, git-attributes flips).
|
|
3518
3876
|
content = normalizeAttestationBytes(raw);
|
|
3519
3877
|
}
|
|
3520
3878
|
catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
|
|
@@ -3534,13 +3892,14 @@ function verifyAttestationSidecar(attFile) {
|
|
|
3534
3892
|
}
|
|
3535
3893
|
|
|
3536
3894
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
3537
|
-
|
|
3538
|
-
//
|
|
3539
|
-
// through to walkAttestationDir, where
|
|
3540
|
-
//
|
|
3895
|
+
const crypto = require("crypto");
|
|
3896
|
+
// Validate --since as ISO-8601, mirroring `attest list --since`. An
|
|
3897
|
+
// invalid date would otherwise pass through to walkAttestationDir, where
|
|
3898
|
+
// the lexical comparison either matches all or none unpredictably.
|
|
3541
3899
|
if (args.since != null) {
|
|
3542
|
-
//
|
|
3543
|
-
// otherwise parse as the year 1999 and silently filter wrong
|
|
3900
|
+
// ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
|
|
3901
|
+
// would otherwise parse as the year 1999 and silently filter wrong
|
|
3902
|
+
// eras.
|
|
3544
3903
|
const sinceErr = validateIsoSince(args.since);
|
|
3545
3904
|
if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
|
|
3546
3905
|
}
|
|
@@ -3564,35 +3923,31 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3564
3923
|
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
3565
3924
|
}
|
|
3566
3925
|
|
|
3567
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
// verdict
|
|
3926
|
+
// Verify the .sig sidecar BEFORE consuming the prior attestation. A
|
|
3927
|
+
// tampered attestation.json would otherwise be silently parsed and the
|
|
3928
|
+
// drift verdict computed against forged input. Refuse on verify-fail
|
|
3570
3929
|
// with exit 6 (TAMPERED) unless --force-replay is explicitly set.
|
|
3571
3930
|
// Unsigned attestations (no private key was available at run time) emit
|
|
3572
3931
|
// a stderr warning but proceed — that's an operator config issue, not
|
|
3573
3932
|
// tampering. `verified === false && signed === true` is the real tamper
|
|
3574
3933
|
// signal.
|
|
3575
3934
|
const verify = verifyAttestationSidecar(attFile);
|
|
3576
|
-
//
|
|
3577
|
-
//
|
|
3578
|
-
//
|
|
3579
|
-
//
|
|
3580
|
-
//
|
|
3581
|
-
//
|
|
3582
|
-
// NOTE branch and replay proceeded against forged input.
|
|
3935
|
+
// Collapse tamper-class detection. Any non-benign sidecar state
|
|
3936
|
+
// (signed-but-invalid, sidecar-corrupt, unsigned-substitution) refuses
|
|
3937
|
+
// replay unless --force-replay is set. A predicate of only
|
|
3938
|
+
// `verify.signed && !verify.verified` would miss corrupt-JSON sidecars
|
|
3939
|
+
// and substituted "unsigned" sidecars on a host WITH a private key —
|
|
3940
|
+
// both of which let replay proceed against forged input.
|
|
3583
3941
|
const isSignedTamper = verify.signed && !verify.verified;
|
|
3584
3942
|
const isClassTamper = !verify.signed && (
|
|
3585
3943
|
verify.tamper_class === "sidecar-corrupt"
|
|
3586
3944
|
|| verify.tamper_class === "unsigned-substitution"
|
|
3587
|
-
//
|
|
3588
|
-
//
|
|
3589
|
-
//
|
|
3590
|
-
//
|
|
3591
|
-
//
|
|
3592
|
-
//
|
|
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.
|
|
3945
|
+
// Extend tamper-class refusal to algorithm-unsupported sidecars —
|
|
3946
|
+
// anything other than "Ed25519" or "unsigned". Without explicit
|
|
3947
|
+
// refusal, a sidecar that throws inside crypto.verify (e.g.
|
|
3948
|
+
// signature_base64 missing on a downgrade-bait shape) emerges as
|
|
3949
|
+
// signed:true + verified:false through the catch block by accident.
|
|
3950
|
+
// The strict pre-check surfaces the class directly; refuse on it too.
|
|
3596
3951
|
|| verify.tamper_class === "algorithm-unsupported"
|
|
3597
3952
|
);
|
|
3598
3953
|
if ((isSignedTamper || isClassTamper) && !args["force-replay"]) {
|
|
@@ -3607,7 +3962,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3607
3962
|
hint: "If you have inspected the attestation and the divergence is benign (e.g. you re-signed manually), pass --force-replay.",
|
|
3608
3963
|
};
|
|
3609
3964
|
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3610
|
-
process.exitCode =
|
|
3965
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
3611
3966
|
return;
|
|
3612
3967
|
}
|
|
3613
3968
|
if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
|
|
@@ -3634,7 +3989,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3634
3989
|
hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
|
|
3635
3990
|
};
|
|
3636
3991
|
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3637
|
-
process.exitCode =
|
|
3992
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
3638
3993
|
return;
|
|
3639
3994
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
|
|
3640
3995
|
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`);
|
|
@@ -3658,7 +4013,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3658
4013
|
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
4014
|
};
|
|
3660
4015
|
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3661
|
-
process.exitCode =
|
|
4016
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
3662
4017
|
return;
|
|
3663
4018
|
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
|
|
3664
4019
|
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`);
|
|
@@ -3688,16 +4043,32 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3688
4043
|
// Fallback: synthesise pass-through preconditions from the playbook so the
|
|
3689
4044
|
// replay isn't blocked when the operator didn't originally pass them.
|
|
3690
4045
|
try {
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
4046
|
+
// Defense-in-depth: the prior attestation's playbook_id came from
|
|
4047
|
+
// disk, but a malicious or corrupt prior could still smuggle an
|
|
4048
|
+
// invalid id. validateIdComponent refuses anything outside the
|
|
4049
|
+
// canonical playbook-id shape.
|
|
4050
|
+
const r = validateIdComponent(prior.playbook_id, "playbook");
|
|
4051
|
+
if (r.ok) {
|
|
4052
|
+
const pb = runner.loadPlaybook(prior.playbook_id);
|
|
4053
|
+
const synth = {};
|
|
4054
|
+
for (const pc of (pb._meta && pb._meta.preconditions) || []) synth[pc.id] = true;
|
|
4055
|
+
replayOpts.precondition_checks = synth;
|
|
4056
|
+
}
|
|
3695
4057
|
} catch { /* ignore */ }
|
|
3696
4058
|
}
|
|
3697
4059
|
const replay = runner.run(prior.playbook_id, prior.directive_id, emptySubmission, replayOpts);
|
|
3698
4060
|
|
|
3699
4061
|
if (!replay || replay.ok === false) {
|
|
3700
|
-
|
|
4062
|
+
// When replay.reason is falsy, dump the available keys so an operator
|
|
4063
|
+
// can correlate the failure to a body field — pre-fix the error message
|
|
4064
|
+
// bottomed out at "unknown" with no breadcrumb into the runner output.
|
|
4065
|
+
const reason = (replay && replay.reason) || (replay && replay.error) || null;
|
|
4066
|
+
const keys = replay && typeof replay === "object" ? Object.keys(replay).join(",") : "(no body)";
|
|
4067
|
+
return emitError(
|
|
4068
|
+
`reattest: replay failed: ${reason || `no reason field — replay body keys: [${keys}]`}`,
|
|
4069
|
+
{ replay, replay_body_keys: replay && typeof replay === "object" ? Object.keys(replay) : null },
|
|
4070
|
+
pretty
|
|
4071
|
+
);
|
|
3701
4072
|
}
|
|
3702
4073
|
|
|
3703
4074
|
const priorHash = prior.evidence_hash;
|
|
@@ -3722,52 +4093,80 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3722
4093
|
const sidecarVerifyClass = classifySidecarVerify(verify);
|
|
3723
4094
|
const forceReplay = !!args["force-replay"];
|
|
3724
4095
|
|
|
3725
|
-
//
|
|
3726
|
-
//
|
|
3727
|
-
// force-replay
|
|
3728
|
-
//
|
|
3729
|
-
//
|
|
3730
|
-
//
|
|
3731
|
-
//
|
|
3732
|
-
//
|
|
3733
|
-
//
|
|
4096
|
+
// Persist a `replay-<isoZ>.json` record under the session directory for
|
|
4097
|
+
// every cmdReattest replay verdict. Without disk persistence, a
|
|
4098
|
+
// force-replay override emitted to stdout becomes invisible to any
|
|
4099
|
+
// subsequent auditor once the operator's shell closes. Each replay
|
|
4100
|
+
// writes a new file alongside the original attestation.json, signed via
|
|
4101
|
+
// the standard maybeSignAttestation path so the audit chain remains
|
|
4102
|
+
// tamper-evident. The file is picked up automatically by
|
|
4103
|
+
// `attest verify <sid>` (which iterates every *.json under the session
|
|
4104
|
+
// dir).
|
|
3734
4105
|
//
|
|
3735
4106
|
// Filename shape: ISO-8601 uses ':' which the persistAttestation regex
|
|
3736
4107
|
// refuses; substitute ':' with '-' and keep millisecond precision so
|
|
3737
4108
|
// multiple replays in the same second do not collide on EEXIST. The
|
|
3738
4109
|
// resulting filename satisfies /^[A-Za-z0-9._-]{1,64}\.json$/.
|
|
3739
|
-
const
|
|
4110
|
+
const replayBaseName = "replay-" + replayedAt.replace(/:/g, "-");
|
|
4111
|
+
const replayBody = {
|
|
4112
|
+
kind: "replay",
|
|
4113
|
+
session_id: sessionId,
|
|
4114
|
+
playbook_id: prior.playbook_id,
|
|
4115
|
+
directive_id: prior.directive_id,
|
|
4116
|
+
status,
|
|
4117
|
+
prior_evidence_hash: priorHash,
|
|
4118
|
+
replay_evidence_hash: newHash,
|
|
4119
|
+
prior_captured_at: prior.captured_at,
|
|
4120
|
+
replayed_at: replayedAt,
|
|
4121
|
+
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
4122
|
+
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
4123
|
+
sidecar_verify: verify,
|
|
4124
|
+
sidecar_verify_class: sidecarVerifyClass,
|
|
4125
|
+
force_replay: forceReplay,
|
|
4126
|
+
};
|
|
3740
4127
|
let replayPersisted = null;
|
|
4128
|
+
let replayPath = null;
|
|
3741
4129
|
try {
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
replayPersisted = { ok: true, path: replayPath };
|
|
4130
|
+
// Retry on EEXIST: two concurrent reattests sharing the same
|
|
4131
|
+
// millisecond timestamp would collide on the base name. Append a short
|
|
4132
|
+
// random suffix until O_EXCL accepts the write or the cap is exhausted.
|
|
4133
|
+
const dir = path.dirname(attFile);
|
|
4134
|
+
const MAX_SUFFIX_TRIES = 8;
|
|
4135
|
+
let written = false;
|
|
4136
|
+
let lastErr = null;
|
|
4137
|
+
for (let i = 0; i < MAX_SUFFIX_TRIES; i++) {
|
|
4138
|
+
const suffix = i === 0 ? "" : "-" + crypto.randomBytes(3).toString("hex");
|
|
4139
|
+
const candidate = path.join(dir, replayBaseName + suffix + ".json");
|
|
4140
|
+
try {
|
|
4141
|
+
fs.writeFileSync(candidate, JSON.stringify(replayBody, null, 2), { flag: "wx" });
|
|
4142
|
+
replayPath = candidate;
|
|
4143
|
+
written = true;
|
|
4144
|
+
break;
|
|
4145
|
+
} catch (e) {
|
|
4146
|
+
lastErr = e;
|
|
4147
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
if (!written) throw lastErr || new Error("replay-record write: EEXIST after " + MAX_SUFFIX_TRIES + " attempts");
|
|
4151
|
+
replayPersisted = { ok: true, path: replayPath, sidecar_signed: true };
|
|
3764
4152
|
} catch (e) {
|
|
3765
|
-
// Non-fatal —
|
|
4153
|
+
// Non-fatal — stdout emit is the operator's primary surface; a
|
|
3766
4154
|
// disk-persistence failure shouldn't mask the verdict. Surface the
|
|
3767
4155
|
// condition in the response body so an operator-side audit pipeline
|
|
3768
4156
|
// can re-run the persist later.
|
|
3769
4157
|
replayPersisted = { ok: false, error: String((e && e.message) || e) };
|
|
3770
4158
|
}
|
|
4159
|
+
if (replayPersisted && replayPersisted.ok && replayPath) {
|
|
4160
|
+
// Sidecar signing is best-effort: the unsigned replay record on disk
|
|
4161
|
+
// is still a valid audit-trail entry. Split from the write try{} so a
|
|
4162
|
+
// sign-time failure doesn't mask a successful write.
|
|
4163
|
+
try {
|
|
4164
|
+
maybeSignAttestation(replayPath);
|
|
4165
|
+
} catch (e) {
|
|
4166
|
+
replayPersisted.sidecar_signed = false;
|
|
4167
|
+
replayPersisted.sidecar_sign_error = String((e && e.message) || e);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
3771
4170
|
|
|
3772
4171
|
emit({
|
|
3773
4172
|
ok: true,
|
|
@@ -3782,7 +4181,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3782
4181
|
replayed_at: replayedAt,
|
|
3783
4182
|
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
3784
4183
|
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
3785
|
-
//
|
|
4184
|
+
// Persist the sidecar verify result + the force-replay flag so the
|
|
3786
4185
|
// audit trail records whether the replay was authenticated input.
|
|
3787
4186
|
sidecar_verify: verify,
|
|
3788
4187
|
// emit a one-token classification label alongside the
|
|
@@ -3800,8 +4199,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3800
4199
|
// 'no-public-key' — infra-missing (operator-side keys/public.pem absent)
|
|
3801
4200
|
sidecar_verify_class: sidecarVerifyClass,
|
|
3802
4201
|
force_replay: forceReplay,
|
|
3803
|
-
//
|
|
3804
|
-
//
|
|
4202
|
+
// Surface the persisted replay-record path (or persistence failure
|
|
4203
|
+
// reason) so an auditor reading the CLI response can locate the
|
|
3805
4204
|
// on-disk artifact without re-deriving the filename.
|
|
3806
4205
|
replay_persisted: replayPersisted,
|
|
3807
4206
|
}, pretty);
|
|
@@ -3819,7 +4218,7 @@ function classifySidecarVerify(verify) {
|
|
|
3819
4218
|
if (verify.signed && !verify.verified) return "tampered";
|
|
3820
4219
|
if (verify.tamper_class === "sidecar-corrupt") return "sidecar-corrupt";
|
|
3821
4220
|
if (verify.tamper_class === "unsigned-substitution") return "unsigned-substitution";
|
|
3822
|
-
//
|
|
4221
|
+
// `algorithm-unsupported` is its own class label so log scrapers /
|
|
3823
4222
|
// dashboards can filter downgrade-bait events without parsing the reason.
|
|
3824
4223
|
if (verify.tamper_class === "algorithm-unsupported") return "algorithm-unsupported";
|
|
3825
4224
|
if (typeof verify.reason === "string" && verify.reason.startsWith("attestation explicitly unsigned")) return "explicitly-unsigned";
|
|
@@ -3851,14 +4250,18 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3851
4250
|
return cmdListAttestations(runner, args, runOpts, pretty);
|
|
3852
4251
|
}
|
|
3853
4252
|
if (!sessionId) {
|
|
3854
|
-
return emitError(
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
//
|
|
3861
|
-
//
|
|
4253
|
+
return emitError(
|
|
4254
|
+
`attest ${subverb}: missing <session-id> positional argument. Inventory prior sessions with \`exceptd attest list\`; or pass \`--latest\` to operate on the most recent.`,
|
|
4255
|
+
{ verb: `attest ${subverb}` },
|
|
4256
|
+
pretty
|
|
4257
|
+
);
|
|
4258
|
+
}
|
|
4259
|
+
// Distinguish "validation rejected" from "valid format but not found".
|
|
4260
|
+
// findSessionDir() returns null for BOTH (regex-rejected ids collapse to
|
|
4261
|
+
// the "no session dir" message), which gives operators a misleading
|
|
4262
|
+
// error — a string with `..` or `/` looks to them like an existing-
|
|
4263
|
+
// session lookup that failed, not a refusal. Call the same validator
|
|
4264
|
+
// up front; emit its specific message when it throws.
|
|
3862
4265
|
try { validateSessionIdForRead(sessionId); }
|
|
3863
4266
|
catch (e) {
|
|
3864
4267
|
return emitError(`attest ${subverb}: ${e.message}`, { session_id_input: typeof sessionId === "string" ? sessionId.slice(0, 80) : typeof sessionId }, pretty);
|
|
@@ -3869,13 +4272,24 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3869
4272
|
}
|
|
3870
4273
|
|
|
3871
4274
|
const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
4275
|
+
// Partition session-dir JSON files by parsed `kind` field. Replay records
|
|
4276
|
+
// (written by `cmdReattest`) live alongside attestations under the same
|
|
4277
|
+
// session directory but represent audit-trail entries, not separate
|
|
4278
|
+
// sessions. Gate on the parsed payload — not filename prefix — so a
|
|
4279
|
+
// renamed file cannot smuggle a replay into the attestations[] list.
|
|
4280
|
+
const attestations = [];
|
|
4281
|
+
const replays = [];
|
|
4282
|
+
for (const f of files) {
|
|
4283
|
+
let parsed;
|
|
4284
|
+
try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
4285
|
+
catch { continue; }
|
|
4286
|
+
if (!parsed) continue;
|
|
4287
|
+
if (parsed.kind === "replay") replays.push(parsed);
|
|
4288
|
+
else attestations.push(parsed);
|
|
4289
|
+
}
|
|
3876
4290
|
|
|
3877
4291
|
if (subverb === "show") {
|
|
3878
|
-
emit({ session_id: sessionId, attestations }, pretty);
|
|
4292
|
+
emit({ session_id: sessionId, attestations, attestation_replays: replays }, pretty);
|
|
3879
4293
|
return;
|
|
3880
4294
|
}
|
|
3881
4295
|
|
|
@@ -3890,10 +4304,37 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3890
4304
|
return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
|
|
3891
4305
|
}
|
|
3892
4306
|
const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3893
|
-
|
|
4307
|
+
// Pick the comparison target deterministically:
|
|
4308
|
+
// 1. Prefer attestation.json (the canonical write-path filename).
|
|
4309
|
+
// 2. Otherwise, walk every non-replay JSON in the dir, sort by
|
|
4310
|
+
// parsed.captured_at descending, and take the newest.
|
|
4311
|
+
// 3. Replay records (kind === "replay") are audit-trail entries,
|
|
4312
|
+
// not attestations — skip them so a replay file sorted ahead of
|
|
4313
|
+
// attestation.json cannot shadow the real attestation in the
|
|
4314
|
+
// diff.
|
|
4315
|
+
let other = null;
|
|
4316
|
+
const otherAttestationPath = path.join(otherDir, "attestation.json");
|
|
4317
|
+
if (fs.existsSync(otherAttestationPath)) {
|
|
4318
|
+
try {
|
|
4319
|
+
const parsed = JSON.parse(fs.readFileSync(otherAttestationPath, "utf8"));
|
|
4320
|
+
if (parsed && parsed.kind !== "replay") other = parsed;
|
|
4321
|
+
} catch { /* fall through to scan */ }
|
|
4322
|
+
}
|
|
4323
|
+
if (!other) {
|
|
4324
|
+
const candidates = [];
|
|
4325
|
+
for (const f of otherFiles) {
|
|
4326
|
+
try {
|
|
4327
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(otherDir, f), "utf8"));
|
|
4328
|
+
if (!parsed || parsed.kind === "replay") continue;
|
|
4329
|
+
candidates.push(parsed);
|
|
4330
|
+
} catch { /* skip malformed */ }
|
|
4331
|
+
}
|
|
4332
|
+
candidates.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
|
|
4333
|
+
other = candidates[0] || null;
|
|
4334
|
+
}
|
|
4335
|
+
if (!other) {
|
|
3894
4336
|
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
3895
4337
|
}
|
|
3896
|
-
const other = JSON.parse(fs.readFileSync(path.join(otherDir, otherFiles[0]), "utf8"));
|
|
3897
4338
|
const self = attestations[0];
|
|
3898
4339
|
emit({
|
|
3899
4340
|
verb: "attest diff",
|
|
@@ -3954,15 +4395,14 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3954
4395
|
// tampered attestation.json and overwrote .sig with the unsigned stub).
|
|
3955
4396
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
3956
4397
|
const hasPrivKey = fs.existsSync(privKeyPath);
|
|
3957
|
-
|
|
4398
|
+
|
|
4399
|
+
// Sidecar-verify helper shared by both the attestations[] and
|
|
4400
|
+
// replay-records[] partitions. Centralising the per-file verify
|
|
4401
|
+
// logic means a future tamper-class addition lands in one place
|
|
4402
|
+
// instead of two parallel branches.
|
|
4403
|
+
const verifySidecar = (f) => {
|
|
3958
4404
|
const sigPath = path.join(dir, f + ".sig");
|
|
3959
4405
|
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
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
4406
|
let sigDoc;
|
|
3967
4407
|
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
3968
4408
|
catch (e) {
|
|
@@ -3975,7 +4415,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3975
4415
|
};
|
|
3976
4416
|
}
|
|
3977
4417
|
if (sigDoc.algorithm === "unsigned") {
|
|
3978
|
-
// substitution detection.
|
|
3979
4418
|
if (hasPrivKey) {
|
|
3980
4419
|
return {
|
|
3981
4420
|
file: f,
|
|
@@ -3987,11 +4426,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3987
4426
|
}
|
|
3988
4427
|
return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
3989
4428
|
}
|
|
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
4429
|
if (sigDoc.algorithm !== "Ed25519") {
|
|
3996
4430
|
return {
|
|
3997
4431
|
file: f,
|
|
@@ -4002,8 +4436,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4002
4436
|
};
|
|
4003
4437
|
}
|
|
4004
4438
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
4005
|
-
// C: normalize before crypto.verify — mirrors the signer
|
|
4006
|
-
// path so the verify pair is byte-stable across CRLF / BOM churn.
|
|
4007
4439
|
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
4008
4440
|
const content = normalizeAttestationBytes(rawContent);
|
|
4009
4441
|
try {
|
|
@@ -4014,33 +4446,58 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4014
4446
|
} catch (e) {
|
|
4015
4447
|
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
4016
4448
|
}
|
|
4017
|
-
}
|
|
4018
|
-
|
|
4019
|
-
//
|
|
4020
|
-
//
|
|
4021
|
-
//
|
|
4022
|
-
//
|
|
4023
|
-
//
|
|
4024
|
-
//
|
|
4025
|
-
//
|
|
4026
|
-
//
|
|
4027
|
-
//
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4449
|
+
};
|
|
4450
|
+
|
|
4451
|
+
// Partition session-dir files by the parsed `kind` field so the verify
|
|
4452
|
+
// output cleanly separates attestations from replay records. Mixing
|
|
4453
|
+
// both into a single `results` array let a replay-record tamper event
|
|
4454
|
+
// promote exit 6 against the operator's expectation that the
|
|
4455
|
+
// attestation itself was the integrity-critical artifact. With the
|
|
4456
|
+
// partition: attestation tamper → exit 6 (operator must investigate);
|
|
4457
|
+
// replay-record tamper → audit-trail warning only (exit stays 0 so
|
|
4458
|
+
// CI gates don't fail on a corrupted audit log they can simply
|
|
4459
|
+
// regenerate via `reattest`).
|
|
4460
|
+
const attResults = [];
|
|
4461
|
+
const replayResults = [];
|
|
4462
|
+
for (const f of files) {
|
|
4463
|
+
let parsed = null;
|
|
4464
|
+
try { parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
|
|
4465
|
+
catch { /* unparseable JSON — treat as attestation so tamper detection still surfaces */ }
|
|
4466
|
+
const verdict = verifySidecar(f);
|
|
4467
|
+
if (parsed && parsed.kind === "replay") {
|
|
4468
|
+
replayResults.push(Object.assign({ replayed_at: parsed.replayed_at || null }, verdict));
|
|
4469
|
+
} else {
|
|
4470
|
+
attResults.push(Object.assign({ captured_at: parsed && parsed.captured_at || null }, verdict));
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
// Deterministic ordering so the output diffs cleanly across runs.
|
|
4474
|
+
attResults.sort((a, b) => (a.captured_at || "").localeCompare(b.captured_at || ""));
|
|
4475
|
+
replayResults.sort((a, b) => (a.replayed_at || "").localeCompare(b.replayed_at || ""));
|
|
4476
|
+
|
|
4477
|
+
const tamperPredicate = (r) =>
|
|
4032
4478
|
(r.signed && !r.verified)
|
|
4033
4479
|
|| r.tamper_class === "sidecar-corrupt"
|
|
4034
4480
|
|| r.tamper_class === "unsigned-substitution"
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
const body = {
|
|
4040
|
-
|
|
4481
|
+
|| r.tamper_class === "algorithm-unsupported";
|
|
4482
|
+
const attTampered = attResults.some(tamperPredicate);
|
|
4483
|
+
const replayTampered = replayResults.some(tamperPredicate);
|
|
4484
|
+
|
|
4485
|
+
const body = {
|
|
4486
|
+
verb: "attest verify",
|
|
4487
|
+
session_id: sessionId,
|
|
4488
|
+
results: attResults,
|
|
4489
|
+
replay_results: replayResults,
|
|
4490
|
+
};
|
|
4491
|
+
if (attTampered) {
|
|
4041
4492
|
body.ok = false;
|
|
4042
4493
|
body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
|
|
4043
|
-
process.exitCode =
|
|
4494
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
4495
|
+
} else if (replayTampered) {
|
|
4496
|
+
// Replay-record tamper is an audit-trail signal but not an
|
|
4497
|
+
// attestation-integrity violation; surface a warning so operators
|
|
4498
|
+
// see the corruption without promoting the exit code.
|
|
4499
|
+
body.replay_tamper = true;
|
|
4500
|
+
body.warnings = ["one or more replay records failed Ed25519 verification — audit-trail corruption suspected, regenerate via reattest"];
|
|
4044
4501
|
}
|
|
4045
4502
|
emit(body, pretty);
|
|
4046
4503
|
return;
|
|
@@ -4442,6 +4899,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4442
4899
|
const wantJson = !!args.json || !!args.pretty;
|
|
4443
4900
|
const indent = !!args.pretty;
|
|
4444
4901
|
|
|
4902
|
+
// `doctor --exit-codes` dumps the canonical exit-code table as JSON so
|
|
4903
|
+
// operator-facing docs cannot drift from runtime behavior. Short-circuit
|
|
4904
|
+
// before the regular health checks since the dump is informational.
|
|
4905
|
+
if (args["exit-codes"]) {
|
|
4906
|
+
emit({ verb: "doctor", exit_codes: listExitCodes() }, pretty);
|
|
4907
|
+
return;
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4445
4910
|
// Selective subchecks. If any of the four flags is passed, run only those.
|
|
4446
4911
|
// If none are passed, run all four plus signing-status.
|
|
4447
4912
|
const onlySigs = !!args.signatures;
|
|
@@ -4651,6 +5116,17 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4651
5116
|
// workflows aren't disturbed. Routed through a child process to keep
|
|
4652
5117
|
// cmdDoctor synchronous + bound the network timeout cleanly.
|
|
4653
5118
|
if (args["registry-check"]) {
|
|
5119
|
+
// Refuse network egress when air-gap mode is active. Surface as a
|
|
5120
|
+
// skipped check (informational), not an error — the operator opted
|
|
5121
|
+
// into air-gap and would otherwise see a confusing network-error
|
|
5122
|
+
// result from the upstream-check probe.
|
|
5123
|
+
if (runOpts && runOpts.airGap) {
|
|
5124
|
+
checks.registry = {
|
|
5125
|
+
ok: null,
|
|
5126
|
+
skipped: "air-gap",
|
|
5127
|
+
reason: "registry probe disabled in air-gap mode",
|
|
5128
|
+
};
|
|
5129
|
+
} else {
|
|
4654
5130
|
try {
|
|
4655
5131
|
const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
|
|
4656
5132
|
const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
|
|
@@ -4678,6 +5154,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4678
5154
|
} catch (e) {
|
|
4679
5155
|
checks.registry = { ok: false, severity: "warn", error: e.message };
|
|
4680
5156
|
}
|
|
5157
|
+
}
|
|
4681
5158
|
}
|
|
4682
5159
|
|
|
4683
5160
|
// Walk every check and split: errors (severity error/missing/fail) vs warnings
|
|
@@ -4736,10 +5213,15 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4736
5213
|
lines.push("exceptd doctor");
|
|
4737
5214
|
function mark(c, render) {
|
|
4738
5215
|
if (!c) return;
|
|
4739
|
-
//
|
|
4740
|
-
//
|
|
4741
|
-
//
|
|
4742
|
-
|
|
5216
|
+
// Four states: ok / warn / error / skipped. `skipped` is informational
|
|
5217
|
+
// (e.g. air-gap mode disabled the network probe) and renders as
|
|
5218
|
+
// [info] so it doesn't read like a failure to operators scanning the
|
|
5219
|
+
// checklist. Three pre-existing states retained.
|
|
5220
|
+
let icon;
|
|
5221
|
+
if (c.skipped) icon = "[info]";
|
|
5222
|
+
else if (c.ok && c.severity !== "warn") icon = "[ok]";
|
|
5223
|
+
else if (c.severity === "warn") icon = "[!! warn]";
|
|
5224
|
+
else icon = "[!! fail]";
|
|
4743
5225
|
lines.push(` ${icon} ${render(c)}`);
|
|
4744
5226
|
}
|
|
4745
5227
|
mark(checks.signatures, c =>
|
|
@@ -4836,8 +5318,9 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4836
5318
|
// Prior behavior silently accepted any string and lexically compared to
|
|
4837
5319
|
// captured_at, producing 0-result or full-result depending on the string.
|
|
4838
5320
|
if (args.since != null) {
|
|
4839
|
-
//
|
|
4840
|
-
// otherwise parse as the year 1999 and silently filter wrong
|
|
5321
|
+
// ISO-8601 shape regex BEFORE Date.parse — bare integers like "99"
|
|
5322
|
+
// would otherwise parse as the year 1999 and silently filter wrong
|
|
5323
|
+
// eras.
|
|
4841
5324
|
const sinceErr = validateIsoSince(args.since);
|
|
4842
5325
|
if (sinceErr) return emitError(`attest list: ${sinceErr}`, null, pretty);
|
|
4843
5326
|
}
|
|
@@ -4858,6 +5341,11 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
4858
5341
|
for (const f of files) {
|
|
4859
5342
|
try {
|
|
4860
5343
|
const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
|
|
5344
|
+
// replay-<isoZ>.json records share the session dir with
|
|
5345
|
+
// attestation.json but are not separate sessions. Gate on the
|
|
5346
|
+
// parsed `kind` field rather than filename so a rename cannot
|
|
5347
|
+
// smuggle a replay record into the listing.
|
|
5348
|
+
if (j && j.kind === "replay") continue;
|
|
4861
5349
|
// v0.12.14: normalized array-set filter (see top of fn).
|
|
4862
5350
|
if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
|
|
4863
5351
|
if (args.since && (j.captured_at || "") < args.since) continue;
|
|
@@ -4919,12 +5407,13 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4919
5407
|
if (!playbookId) {
|
|
4920
5408
|
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
4921
5409
|
}
|
|
5410
|
+
if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
|
|
4922
5411
|
let pb;
|
|
4923
5412
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
4924
5413
|
catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
|
|
4925
5414
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
4926
5415
|
if (!directiveId) {
|
|
4927
|
-
return
|
|
5416
|
+
return refuseNoDirectives("ai-run", playbookId, pretty);
|
|
4928
5417
|
}
|
|
4929
5418
|
|
|
4930
5419
|
// Compute the informational phases up front — both stream and no-stream
|
|
@@ -4935,7 +5424,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4935
5424
|
directPhase = runner.direct(playbookId, directiveId);
|
|
4936
5425
|
lookPhase = runner.look(playbookId, directiveId, runOpts);
|
|
4937
5426
|
} catch (e) {
|
|
4938
|
-
//
|
|
5427
|
+
// process.exit(1) immediately after a stdout write can
|
|
4939
5428
|
// truncate buffered output under piped consumers (same class as v0.11.10
|
|
4940
5429
|
// #100). Use exitCode+return so the JSONL error frame drains. Also write
|
|
4941
5430
|
// the framed error event so the stdout-only JSONL contract holds — host
|
|
@@ -4984,7 +5473,7 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
4984
5473
|
try { payload = readEvidence(args.evidence); }
|
|
4985
5474
|
catch (e) { return emitError(`ai-run: failed to read --evidence: ${e.message}`, null, pretty); }
|
|
4986
5475
|
} else if (hasReadableStdin()) {
|
|
4987
|
-
//
|
|
5476
|
+
// hasReadableStdin() probes via fstat before falling into
|
|
4988
5477
|
// readFileSync(0). Wrapped-stdin test harnesses (isTTY===undefined,
|
|
4989
5478
|
// size===0) would otherwise hang here.
|
|
4990
5479
|
// Drain stdin for any evidence event.
|
|
@@ -5018,7 +5507,11 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5018
5507
|
try {
|
|
5019
5508
|
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
5020
5509
|
} catch (e) {
|
|
5021
|
-
return emitError(
|
|
5510
|
+
return emitError(
|
|
5511
|
+
`ai-run: internal error (${e && e.message ? e.message : String(e)}). Re-run with --pretty for context; file at https://github.com/blamejs/exceptd-skills/issues if reproducible.`,
|
|
5512
|
+
{ playbook: playbookId, verb: "ai-run" },
|
|
5513
|
+
pretty
|
|
5514
|
+
);
|
|
5022
5515
|
}
|
|
5023
5516
|
if (!result || result.ok === false) {
|
|
5024
5517
|
// v0.12.12: same exit-after-write anti-pattern as the pre-stream
|
|
@@ -5056,21 +5549,26 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5056
5549
|
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
5057
5550
|
// Collision without --force-overwrite. AI agents typically pass
|
|
5058
5551
|
// unique session ids each run, so this path is rare but surface
|
|
5059
|
-
// it cleanly via the same JSONL contract.
|
|
5060
|
-
//
|
|
5061
|
-
//
|
|
5552
|
+
// it cleanly via the same JSONL contract. Three exit-code classes
|
|
5553
|
+
// (LOCK_CONTENTION / STORAGE_EXHAUSTED / SESSION_ID_COLLISION) so
|
|
5554
|
+
// a host-AI driver can branch on remediation without parsing the
|
|
5555
|
+
// reason string.
|
|
5062
5556
|
const eventBody = {
|
|
5063
5557
|
event: "error", reason: persistResult.error,
|
|
5064
5558
|
existing_attestation: persistResult.existingPath,
|
|
5065
5559
|
};
|
|
5066
5560
|
if (persistResult.lock_contention) {
|
|
5067
5561
|
eventBody.lock_contention = true;
|
|
5068
|
-
eventBody.exit_code =
|
|
5562
|
+
eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
5069
5563
|
}
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5564
|
+
if (persistResult.storage_exhausted) {
|
|
5565
|
+
eventBody.storage_exhausted = true;
|
|
5566
|
+
eventBody.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
5073
5567
|
}
|
|
5568
|
+
process.stdout.write(JSON.stringify(eventBody) + "\n");
|
|
5569
|
+
if (persistResult.lock_contention) process.exitCode = EXIT_CODES.LOCK_CONTENTION;
|
|
5570
|
+
else if (persistResult.storage_exhausted) process.exitCode = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
5571
|
+
else process.exitCode = EXIT_CODES.SESSION_ID_COLLISION;
|
|
5074
5572
|
return;
|
|
5075
5573
|
}
|
|
5076
5574
|
}
|
|
@@ -5174,12 +5672,18 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5174
5672
|
existing_attestation: persistResult.existingPath };
|
|
5175
5673
|
if (persistResult.lock_contention) {
|
|
5176
5674
|
eventBody.lock_contention = true;
|
|
5177
|
-
eventBody.exit_code =
|
|
5675
|
+
eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
5178
5676
|
writeLine(eventBody);
|
|
5179
|
-
return finish(
|
|
5677
|
+
return finish(EXIT_CODES.LOCK_CONTENTION);
|
|
5678
|
+
}
|
|
5679
|
+
if (persistResult.storage_exhausted) {
|
|
5680
|
+
eventBody.storage_exhausted = true;
|
|
5681
|
+
eventBody.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
5682
|
+
writeLine(eventBody);
|
|
5683
|
+
return finish(EXIT_CODES.STORAGE_EXHAUSTED);
|
|
5180
5684
|
}
|
|
5181
5685
|
writeLine(eventBody);
|
|
5182
|
-
return finish(
|
|
5686
|
+
return finish(EXIT_CODES.SESSION_ID_COLLISION);
|
|
5183
5687
|
}
|
|
5184
5688
|
}
|
|
5185
5689
|
writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
|
|
@@ -5478,9 +5982,9 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5478
5982
|
const results = [];
|
|
5479
5983
|
let fail = false;
|
|
5480
5984
|
let failReasons = [];
|
|
5481
|
-
//
|
|
5482
|
-
// exit code can distinguish "detected/escalated" (2) from "regulatory
|
|
5483
|
-
// running, operator must notify" (5).
|
|
5985
|
+
// Track jurisdiction-clock signals separately from generic FAIL so the
|
|
5986
|
+
// exit code can distinguish "detected/escalated" (2) from "regulatory
|
|
5987
|
+
// clock running, operator must notify" (5).
|
|
5484
5988
|
let clockStartedFail = false;
|
|
5485
5989
|
let clockStartedReasons = [];
|
|
5486
5990
|
|
|
@@ -5534,11 +6038,10 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5534
6038
|
failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
|
|
5535
6039
|
}
|
|
5536
6040
|
if (blockOnClock && clockStarted) {
|
|
5537
|
-
//
|
|
5538
|
-
//
|
|
5539
|
-
// detected" from "regulatory
|
|
5540
|
-
//
|
|
5541
|
-
// FAIL but not BLOCKED.
|
|
6041
|
+
// Separate "clock started" from generic FAIL: exit 5 (CLOCK_STARTED)
|
|
6042
|
+
// is selected below, taking precedence over FAIL but not BLOCKED, so
|
|
6043
|
+
// operators can distinguish "playbook detected" from "regulatory
|
|
6044
|
+
// clock running."
|
|
5542
6045
|
clockStartedFail = true;
|
|
5543
6046
|
clockStartedReasons.push(`${id}: jurisdiction clock started`);
|
|
5544
6047
|
}
|
|
@@ -5558,7 +6061,7 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5558
6061
|
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
5559
6062
|
const totalForVerdict = results.length;
|
|
5560
6063
|
const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
|
|
5561
|
-
//
|
|
6064
|
+
// Precedence: BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
|
|
5562
6065
|
// CLOCK_STARTED outranks FAIL because the operator explicitly opted into
|
|
5563
6066
|
// the clock gate (--block-on-jurisdiction-clock); when that gate fires,
|
|
5564
6067
|
// they want the regulatory-deadline signal even if a detected finding
|
|
@@ -5614,11 +6117,11 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5614
6117
|
.filter(n => n && n.clock_started_at != null).length,
|
|
5615
6118
|
framework_gap_rollup: frameworkGapRollup,
|
|
5616
6119
|
framework_gap_count: frameworkGapRollup.length,
|
|
5617
|
-
//
|
|
5618
|
-
// buildJurisdictionClockRollup.
|
|
5619
|
-
// one notification entry per contributing playbook (often 8+)
|
|
5620
|
-
// single notification per (jurisdiction, regulation,
|
|
5621
|
-
// window)
|
|
6120
|
+
// Dedupe jurisdiction-clock notifications across playbooks; see
|
|
6121
|
+
// buildJurisdictionClockRollup. Without this, multi-playbook ci runs
|
|
6122
|
+
// produce one notification entry per contributing playbook (often 8+)
|
|
6123
|
+
// when a single notification per (jurisdiction, regulation,
|
|
6124
|
+
// obligation, window) is the right shape.
|
|
5622
6125
|
jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
|
|
5623
6126
|
verdict: computedVerdict,
|
|
5624
6127
|
fail_reasons: failReasons,
|
|
@@ -5653,9 +6156,14 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5653
6156
|
emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
|
|
5654
6157
|
} else if (fmt && fmt !== "json") {
|
|
5655
6158
|
// v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
|
|
5656
|
-
//
|
|
5657
|
-
|
|
5658
|
-
|
|
6159
|
+
// Route through emitError so the body propagates exit codes via the
|
|
6160
|
+
// emit() ok:false contract. ci-format-typo is operator-decision class
|
|
6161
|
+
// (GENERIC_FAILURE), not DETECTED_ESCALATE.
|
|
6162
|
+
emitError(
|
|
6163
|
+
`ci: --format "${fmt}" not in accepted set ["summary","markdown","csaf-2.0","sarif","openvex","json"].`,
|
|
6164
|
+
{ verb: "ci" },
|
|
6165
|
+
pretty
|
|
6166
|
+
);
|
|
5659
6167
|
return;
|
|
5660
6168
|
} else {
|
|
5661
6169
|
emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
|
|
@@ -5678,30 +6186,30 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5678
6186
|
// actually evaluate signals, so it can't be a true detection.
|
|
5679
6187
|
if (summary.blocked > 0) {
|
|
5680
6188
|
const blockedReasons = failReasons.filter(r => r.includes("blocked"));
|
|
5681
|
-
process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit
|
|
5682
|
-
process.exitCode =
|
|
6189
|
+
process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit ${EXIT_CODES.BLOCKED}. Reasons:\n ${blockedReasons.join("\n ")}\n`);
|
|
6190
|
+
process.exitCode = EXIT_CODES.BLOCKED;
|
|
5683
6191
|
return;
|
|
5684
6192
|
}
|
|
5685
|
-
//
|
|
6193
|
+
// Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
|
|
5686
6194
|
// --block-on-jurisdiction-clock; when a clock fires, that's the gate
|
|
5687
6195
|
// result they want to see at the exit-code layer. Per-playbook detected
|
|
5688
6196
|
// findings remain in the body for them to investigate.
|
|
5689
6197
|
if (clockStartedFail) {
|
|
5690
|
-
process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit
|
|
5691
|
-
process.exitCode =
|
|
6198
|
+
process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit ${EXIT_CODES.JURISDICTION_CLOCK_STARTED}.\n`);
|
|
6199
|
+
process.exitCode = EXIT_CODES.JURISDICTION_CLOCK_STARTED;
|
|
5692
6200
|
return;
|
|
5693
6201
|
}
|
|
5694
6202
|
if (fail) {
|
|
5695
6203
|
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
5696
6204
|
// v0.11.11: exitCode + return so emit()'s stdout flushes.
|
|
5697
|
-
process.exitCode =
|
|
6205
|
+
process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
|
|
5698
6206
|
return;
|
|
5699
6207
|
}
|
|
5700
6208
|
const suppliedEvidence = args.evidence || args["evidence-dir"];
|
|
5701
6209
|
const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
|
|
5702
6210
|
if (!suppliedEvidence && allInconclusive) {
|
|
5703
|
-
process.stderr.write(`[exceptd ci] WARN: no --evidence supplied and all ${summary.total} playbook(s) returned inconclusive. CI exit
|
|
5704
|
-
process.exitCode =
|
|
6211
|
+
process.stderr.write(`[exceptd ci] WARN: no --evidence supplied and all ${summary.total} playbook(s) returned inconclusive. CI exit ${EXIT_CODES.RAN_NO_EVIDENCE} = "ran but never had real data." Pass --evidence <file> or --evidence-dir <dir> for a real gate.\n`);
|
|
6212
|
+
process.exitCode = EXIT_CODES.RAN_NO_EVIDENCE;
|
|
5705
6213
|
}
|
|
5706
6214
|
}
|
|
5707
6215
|
|