@blamejs/exceptd-skills 0.12.23 → 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/CHANGELOG.md +107 -3
- package/README.md +14 -1
- package/bin/exceptd.js +584 -166
- package/data/_indexes/_meta.json +2 -2
- package/data/playbooks/containers.json +1 -1
- package/data/playbooks/crypto-codebase.json +1 -1
- package/data/playbooks/framework.json +1 -1
- package/data/playbooks/hardening.json +1 -1
- package/data/playbooks/library-author.json +1 -1
- package/data/playbooks/secrets.json +25 -1
- package/lib/auto-discovery.js +26 -2
- 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 +68 -1
- package/lib/playbook-runner.js +321 -46
- package/lib/prefetch.js +113 -0
- package/lib/refresh-external.js +190 -8
- package/lib/refresh-network.js +35 -8
- package/lib/schemas/cve-catalog.schema.json +7 -1
- package/lib/schemas/playbook.schema.json +51 -0
- package/lib/scoring.js +41 -0
- package/lib/upstream-check-cli.js +16 -1
- package/lib/upstream-check.js +9 -0
- package/lib/verify.js +20 -4
- 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 +9 -0
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
|
|
@@ -277,9 +286,9 @@ v0.12.0 canonical surface
|
|
|
277
286
|
|
|
278
287
|
ci One-shot CI gate. Exit codes: 0 PASS, 1 framework error,
|
|
279
288
|
2 detected/escalate, 3 ran-but-no-evidence,
|
|
280
|
-
4 blocked (ok:false), 5 jurisdiction clock started
|
|
281
|
-
6
|
|
282
|
-
|
|
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.)
|
|
283
292
|
--all | --scope <type> | (auto-detect)
|
|
284
293
|
--max-rwep <n> cap below playbook default
|
|
285
294
|
--block-on-jurisdiction-clock
|
|
@@ -355,6 +364,8 @@ Examples:
|
|
|
355
364
|
exceptd ci --scope code --max-rwep 70 # gate every code playbook
|
|
356
365
|
exceptd ask "I think someone replaced npm packages" # natural-language route
|
|
357
366
|
|
|
367
|
+
Unknown verbs exit 2 with a structured ok:false body on stderr.
|
|
368
|
+
|
|
358
369
|
Full documentation: ${PKG_ROOT}/README.md
|
|
359
370
|
Project rules: ${PKG_ROOT}/AGENTS.md
|
|
360
371
|
`);
|
|
@@ -873,6 +884,10 @@ function detectVexShape(doc) {
|
|
|
873
884
|
}
|
|
874
885
|
|
|
875
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}`);
|
|
876
891
|
const pb = runner.loadPlaybook(playbookId);
|
|
877
892
|
if (!pb.directives || !pb.directives.length) {
|
|
878
893
|
throw new Error(`Playbook ${playbookId} has no directives.`);
|
|
@@ -898,7 +913,9 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
898
913
|
// gate alongside --signatures. doctor --registry-check + --signatures
|
|
899
914
|
// were already accepted; explicit registration removes the silent
|
|
900
915
|
// "unknown bool flag" surface in parseArgs.
|
|
901
|
-
"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"],
|
|
902
919
|
multi: ["playbook", "format"],
|
|
903
920
|
});
|
|
904
921
|
// v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
|
|
@@ -915,34 +932,115 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
915
932
|
// Hoist into module-level state so emit() can read it without plumbing.
|
|
916
933
|
global.__exceptdWantJson = args._jsonMode;
|
|
917
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
|
+
}
|
|
918
1009
|
const runOpts = {
|
|
919
|
-
|
|
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",
|
|
920
1015
|
forceStale: !!args["force-stale"],
|
|
921
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
|
+
}
|
|
922
1028
|
if (args["session-id"]) {
|
|
923
|
-
//
|
|
1029
|
+
// --session-id is a filesystem path component (resolves to
|
|
924
1030
|
// .exceptd/attestations/<id>/attestation.json). Operator-supplied input
|
|
925
|
-
// with `..` or path separators escapes the attestation root.
|
|
926
|
-
//
|
|
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.
|
|
927
1035
|
const sid = args["session-id"];
|
|
928
|
-
|
|
1036
|
+
const r = validateIdComponent(sid, "session");
|
|
1037
|
+
if (!r.ok) {
|
|
929
1038
|
return emitError(
|
|
930
|
-
|
|
1039
|
+
`run: --session-id ${r.reason}. Path separators and '..' are rejected.`,
|
|
931
1040
|
{ provided: typeof sid === "string" ? sid.slice(0, 80) : typeof sid },
|
|
932
1041
|
pretty
|
|
933
1042
|
);
|
|
934
1043
|
}
|
|
935
|
-
// The character-class regex accepts any all-dots string (`.`, `..`,
|
|
936
|
-
// `...`); each resolves into or above the attestation root. Refuse
|
|
937
|
-
// them explicitly so the attestation is never written outside the
|
|
938
|
-
// intended directory.
|
|
939
|
-
if (/^\.+$/.test(sid)) {
|
|
940
|
-
return emitError(
|
|
941
|
-
"run: --session-id cannot consist entirely of dots (rejected: '.', '..', etc.).",
|
|
942
|
-
{ provided: sid },
|
|
943
|
-
pretty
|
|
944
|
-
);
|
|
945
|
-
}
|
|
946
1044
|
runOpts.session_id = sid;
|
|
947
1045
|
}
|
|
948
1046
|
if (args["attestation-root"]) {
|
|
@@ -954,13 +1052,28 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
954
1052
|
if (typeof ar !== "string" || ar.length === 0) {
|
|
955
1053
|
return emitError("run: --attestation-root must be a non-empty string.", { provided: typeof ar }, pretty);
|
|
956
1054
|
}
|
|
957
|
-
|
|
1055
|
+
const arSegments = ar.split(/[\\/]/);
|
|
1056
|
+
if (arSegments.some(seg => seg === "..")) {
|
|
958
1057
|
return emitError(
|
|
959
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.",
|
|
960
1059
|
{ provided: ar.slice(0, 200) },
|
|
961
1060
|
pretty
|
|
962
1061
|
);
|
|
963
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
|
+
}
|
|
964
1077
|
runOpts.attestationRoot = path.resolve(ar);
|
|
965
1078
|
}
|
|
966
1079
|
if (args["session-key"]) {
|
|
@@ -1221,7 +1334,15 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
1221
1334
|
return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
|
|
1222
1335
|
}
|
|
1223
1336
|
}
|
|
1224
|
-
|
|
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
|
+
);
|
|
1225
1346
|
}
|
|
1226
1347
|
}
|
|
1227
1348
|
|
|
@@ -1428,19 +1549,38 @@ Exit codes (per-verb, post-run):
|
|
|
1428
1549
|
3 Ran-but-no-evidence All inconclusive AND no --evidence supplied.
|
|
1429
1550
|
4 Blocked Result returned ok:false (preflight halt).
|
|
1430
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.
|
|
1431
1554
|
8 LOCK_CONTENTION persistAttestation could not acquire the per-slot
|
|
1432
1555
|
attestation lock after the bounded retry budget
|
|
1433
1556
|
(~1-2s). Distinct from 1 so callers can retry the
|
|
1434
1557
|
operation rather than treat it as a hard failure.
|
|
1435
1558
|
Surfaces as body.lock_contention=true,
|
|
1436
1559
|
body.exit_code=8.
|
|
1437
|
-
|
|
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).`,
|
|
1438
1568
|
ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
|
|
1439
1569
|
|
|
1440
1570
|
Flags:
|
|
1441
1571
|
--domain <id> Playbook ID (overrides submission.playbook_id).
|
|
1442
1572
|
--directive <id> Directive ID (overrides submission.directive_id).
|
|
1443
1573
|
--evidence <file|-> Submission JSON. May include playbook_id/directive_id.
|
|
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.
|
|
1444
1584
|
--csaf-status <s> CSAF tracking.status for the close.evidence_package
|
|
1445
1585
|
bundle. One of: draft | interim (default) | final.
|
|
1446
1586
|
'final' commits to CSAF §3.1.11.3.5.1 immutability —
|
|
@@ -1449,7 +1589,10 @@ Flags:
|
|
|
1449
1589
|
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1450
1590
|
operator's organisation URL, NOT the tooling vendor.
|
|
1451
1591
|
Must be an http://… or https://… URL, ≤256 chars.
|
|
1452
|
-
--pretty Indented JSON output
|
|
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.`,
|
|
1453
1596
|
reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
|
|
1454
1597
|
|
|
1455
1598
|
Args / flags:
|
|
@@ -1475,20 +1618,26 @@ Lists every attestation under .exceptd/attestations/<session_id>/, sorted
|
|
|
1475
1618
|
newest-first, with truncated evidence_hash + capture timestamp + file path.`,
|
|
1476
1619
|
attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
|
|
1477
1620
|
|
|
1478
|
-
Subverbs:
|
|
1621
|
+
Subverbs (list | show | export | verify | diff):
|
|
1479
1622
|
attest show <sid> Emit the full (unredacted) attestation.
|
|
1480
1623
|
attest list Inventory every prior attestation under
|
|
1481
1624
|
~/.exceptd/attestations/ (or EXCEPTD_HOME when set).
|
|
1482
|
-
Filter with --playbook <id> or --since <ISO
|
|
1483
|
-
|
|
1484
|
-
|
|
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.
|
|
1485
1629
|
attest export <sid> Emit redacted JSON suitable for audit submission.
|
|
1486
1630
|
Strips raw artifact values; preserves evidence_hash,
|
|
1487
1631
|
signature, classification, RWEP, remediation choice.
|
|
1488
|
-
--format <csaf|
|
|
1489
|
-
|
|
1632
|
+
--format <csaf|csaf-2.0|json> wraps the export
|
|
1633
|
+
(default: redacted JSON; csaf yields a CSAF 2.0
|
|
1634
|
+
envelope).
|
|
1490
1635
|
attest verify <sid> Verify .sig sidecar against keys/public.pem.
|
|
1491
|
-
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).
|
|
1492
1641
|
attest diff <sid> Diff <sid> against the most-recent prior attestation
|
|
1493
1642
|
for the same playbook, or against --against <other-sid>
|
|
1494
1643
|
for an explicit pair. Reports unchanged | drifted |
|
|
@@ -1499,7 +1648,8 @@ All subverbs honor --pretty for indented JSON output.
|
|
|
1499
1648
|
Exit codes (attest verify):
|
|
1500
1649
|
0 verification succeeded
|
|
1501
1650
|
1 generic failure
|
|
1502
|
-
6 TAMPERED (sidecar or signature mismatch
|
|
1651
|
+
6 TAMPERED (sidecar or signature mismatch on an attestation; replay-record
|
|
1652
|
+
tamper warns but exits 0)`,
|
|
1503
1653
|
discover: `discover — context-aware playbook recommender (v0.11.0).
|
|
1504
1654
|
|
|
1505
1655
|
Replaces: scan + dispatch + recommend.
|
|
@@ -1514,7 +1664,11 @@ Flags:
|
|
|
1514
1664
|
--json Emit JSON (default is human-readable text).
|
|
1515
1665
|
--pretty Indented JSON output (implies --json).
|
|
1516
1666
|
|
|
1517
|
-
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.`,
|
|
1518
1672
|
doctor: `doctor — one-shot health check (v0.11.0).
|
|
1519
1673
|
|
|
1520
1674
|
Replaces: currency + verify + validate-cves + validate-rfcs + signing-status.
|
|
@@ -1562,8 +1716,21 @@ Flags:
|
|
|
1562
1716
|
CSAF document.publisher.namespace (§3.1.7.4). The
|
|
1563
1717
|
operator's organisation URL, NOT the tooling vendor.
|
|
1564
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}$/).
|
|
1565
1725
|
--pretty Indented JSON output (single-shot only).
|
|
1566
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
|
+
|
|
1567
1734
|
Stdin event grammar (one JSON object per line):
|
|
1568
1735
|
{"event":"evidence","payload":{"observations":{},"verdict":{}}}
|
|
1569
1736
|
|
|
@@ -1597,7 +1764,9 @@ Args / flags:
|
|
|
1597
1764
|
|
|
1598
1765
|
Output: { verb, question, routed_to:[ids], confidence, next_step,
|
|
1599
1766
|
full_match_list }. Empty match list when no token overlap — surfaces a
|
|
1600
|
-
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).`,
|
|
1601
1770
|
ci: `ci [--all|--scope <type>] — one-shot CI gate (v0.11.0).
|
|
1602
1771
|
|
|
1603
1772
|
Top-level CI verb. Equivalent to \`run --all --ci\` but with a clean
|
|
@@ -1642,13 +1811,9 @@ Exit codes:
|
|
|
1642
1811
|
close.notification_actions entry started a
|
|
1643
1812
|
regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
|
|
1644
1813
|
etc.) and the operator has not acked.
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
not verify against keys/public.pem).
|
|
1649
|
-
8 LOCK_CONTENTION Concurrent run holds the per-playbook attestation
|
|
1650
|
-
lock; the bounded retry budget (~1-2s) elapsed
|
|
1651
|
-
without acquiring it. Retry the operation.
|
|
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\`.)
|
|
1652
1817
|
|
|
1653
1818
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
1654
1819
|
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
@@ -1740,9 +1905,22 @@ function cmdLint(runner, args, runOpts, pretty) {
|
|
|
1740
1905
|
if (!playbookId || !evidencePath) {
|
|
1741
1906
|
return emitError("lint: usage: exceptd lint <playbook> <evidence-file|->", null, pretty);
|
|
1742
1907
|
}
|
|
1908
|
+
if (refuseInvalidPlaybookId("lint", playbookId, pretty)) return;
|
|
1743
1909
|
let pb;
|
|
1744
1910
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
1745
|
-
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
|
+
}
|
|
1746
1924
|
|
|
1747
1925
|
let submission;
|
|
1748
1926
|
try { submission = readEvidence(evidencePath); }
|
|
@@ -1881,6 +2059,7 @@ function cmdBrief(runner, args, runOpts, pretty) {
|
|
|
1881
2059
|
return cmdPlan(runner, args, runOpts, pretty);
|
|
1882
2060
|
}
|
|
1883
2061
|
|
|
2062
|
+
if (refuseInvalidPlaybookId("brief", playbookId, pretty)) return;
|
|
1884
2063
|
const pb = runner.loadPlaybook(playbookId);
|
|
1885
2064
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
1886
2065
|
|
|
@@ -2039,6 +2218,41 @@ function validateScopeOrThrow(scope) {
|
|
|
2039
2218
|
return scope;
|
|
2040
2219
|
}
|
|
2041
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
|
+
|
|
2042
2256
|
function filterPlaybooksByScope(runner, scope) {
|
|
2043
2257
|
validateScopeOrThrow(scope);
|
|
2044
2258
|
const ids = runner.listPlaybooks();
|
|
@@ -2081,27 +2295,30 @@ function detectScopes() {
|
|
|
2081
2295
|
function cmdGovern(runner, args, runOpts, pretty) {
|
|
2082
2296
|
const playbookId = args._[0];
|
|
2083
2297
|
if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
|
|
2298
|
+
if (refuseInvalidPlaybookId("govern", playbookId, pretty)) return;
|
|
2084
2299
|
const pb = runner.loadPlaybook(playbookId);
|
|
2085
2300
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2086
|
-
if (!directiveId) return
|
|
2301
|
+
if (!directiveId) return refuseNoDirectives("govern", playbookId, pretty);
|
|
2087
2302
|
emit(runner.govern(playbookId, directiveId, runOpts), pretty);
|
|
2088
2303
|
}
|
|
2089
2304
|
|
|
2090
2305
|
function cmdDirect(runner, args, pretty) {
|
|
2091
2306
|
const playbookId = args._[0];
|
|
2092
2307
|
if (!playbookId) return emitError("direct: missing <playbookId> positional argument.", null, pretty);
|
|
2308
|
+
if (refuseInvalidPlaybookId("direct", playbookId, pretty)) return;
|
|
2093
2309
|
const pb = runner.loadPlaybook(playbookId);
|
|
2094
2310
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2095
|
-
if (!directiveId) return
|
|
2311
|
+
if (!directiveId) return refuseNoDirectives("direct", playbookId, pretty);
|
|
2096
2312
|
emit(runner.direct(playbookId, directiveId), pretty);
|
|
2097
2313
|
}
|
|
2098
2314
|
|
|
2099
2315
|
function cmdLook(runner, args, runOpts, pretty) {
|
|
2100
2316
|
const playbookId = args._[0];
|
|
2101
2317
|
if (!playbookId) return emitError("look: missing <playbookId> positional argument.", null, pretty);
|
|
2318
|
+
if (refuseInvalidPlaybookId("look", playbookId, pretty)) return;
|
|
2102
2319
|
const pb = runner.loadPlaybook(playbookId);
|
|
2103
2320
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2104
|
-
if (!directiveId) return
|
|
2321
|
+
if (!directiveId) return refuseNoDirectives("look", playbookId, pretty);
|
|
2105
2322
|
emit(runner.look(playbookId, directiveId, runOpts), pretty);
|
|
2106
2323
|
}
|
|
2107
2324
|
|
|
@@ -2132,16 +2349,32 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2132
2349
|
const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
|
|
2133
2350
|
const unique = [...new Set(ids)];
|
|
2134
2351
|
if (unique.length === 0) {
|
|
2135
|
-
|
|
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
|
+
);
|
|
2136
2368
|
}
|
|
2137
2369
|
return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
|
|
2138
2370
|
}
|
|
2139
2371
|
|
|
2140
2372
|
// Single-playbook path (existing behavior).
|
|
2141
2373
|
const playbookId = positional;
|
|
2374
|
+
if (refuseInvalidPlaybookId("run", playbookId, pretty)) return;
|
|
2142
2375
|
const pb = runner.loadPlaybook(playbookId);
|
|
2143
2376
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2144
|
-
if (!directiveId) return
|
|
2377
|
+
if (!directiveId) return refuseNoDirectives("run", playbookId, pretty);
|
|
2145
2378
|
|
|
2146
2379
|
// --explain: dry-run that emits the preconditions + artifacts + indicators
|
|
2147
2380
|
// + signal keys the agent would need to supply, WITHOUT running detect/
|
|
@@ -2369,31 +2602,34 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2369
2602
|
});
|
|
2370
2603
|
if (!persistResult.ok) {
|
|
2371
2604
|
// Session-id collision without --force-overwrite, OR --force-overwrite
|
|
2372
|
-
// lost the lockfile race
|
|
2373
|
-
//
|
|
2374
|
-
//
|
|
2375
|
-
//
|
|
2376
|
-
//
|
|
2377
|
-
//
|
|
2378
|
-
//
|
|
2379
|
-
// process.exitCode = 8 (LOCK_CONTENTION) before returning. Don't
|
|
2380
|
-
// overwrite that with 3 — preserve the exit-8 contract callers depend
|
|
2381
|
-
// on to distinguish lock-busy from collision.
|
|
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.
|
|
2382
2612
|
const err = {
|
|
2383
2613
|
ok: false,
|
|
2384
2614
|
error: persistResult.error,
|
|
2385
2615
|
existing_attestation: persistResult.existingPath,
|
|
2386
|
-
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).",
|
|
2387
2619
|
verb: "run",
|
|
2388
2620
|
};
|
|
2389
2621
|
if (persistResult.lock_contention) {
|
|
2390
2622
|
err.lock_contention = true;
|
|
2391
|
-
err.exit_code =
|
|
2623
|
+
err.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
2392
2624
|
}
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2625
|
+
if (persistResult.storage_exhausted) {
|
|
2626
|
+
err.storage_exhausted = true;
|
|
2627
|
+
err.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
2396
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;
|
|
2397
2633
|
return;
|
|
2398
2634
|
}
|
|
2399
2635
|
if (persistResult.prior_session_id) {
|
|
@@ -2413,7 +2649,7 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2413
2649
|
// --ci the legacy exit 1 is preserved (ok:false bodies are framework
|
|
2414
2650
|
// signals when no CI gating is requested).
|
|
2415
2651
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
2416
|
-
process.exitCode = args.ci ?
|
|
2652
|
+
process.exitCode = args.ci ? EXIT_CODES.BLOCKED : EXIT_CODES.GENERIC_FAILURE;
|
|
2417
2653
|
return;
|
|
2418
2654
|
}
|
|
2419
2655
|
|
|
@@ -2434,11 +2670,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2434
2670
|
result.strict_preconditions_violated = warnIssues.map(i => ({
|
|
2435
2671
|
id: i.id, kind: i.kind, message: i.message || null, on_fail: i.on_fail || null,
|
|
2436
2672
|
}));
|
|
2437
|
-
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`);
|
|
2438
2674
|
emit(result, pretty);
|
|
2439
2675
|
// v0.11.11: exitCode + return so emit()'s stdout flushes (process.exit
|
|
2440
2676
|
// can truncate buffered async stdout writes when piped).
|
|
2441
|
-
process.exitCode =
|
|
2677
|
+
process.exitCode = EXIT_CODES.GENERIC_FAILURE;
|
|
2442
2678
|
return;
|
|
2443
2679
|
}
|
|
2444
2680
|
}
|
|
@@ -2476,9 +2712,9 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2476
2712
|
const refs = startedClocks
|
|
2477
2713
|
.map(n => `${n.obligation_ref || n.jurisdiction || "?"}@${n.clock_started_at}`)
|
|
2478
2714
|
.join("; ");
|
|
2479
|
-
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`);
|
|
2480
2716
|
emit(result, pretty);
|
|
2481
|
-
process.exitCode =
|
|
2717
|
+
process.exitCode = EXIT_CODES.JURISDICTION_CLOCK_STARTED;
|
|
2482
2718
|
return;
|
|
2483
2719
|
}
|
|
2484
2720
|
}
|
|
@@ -2515,12 +2751,12 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
2515
2751
|
// under piped consumers (CI runners, jq, test harnesses).
|
|
2516
2752
|
if (classification === "detected") {
|
|
2517
2753
|
process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
|
|
2518
|
-
process.exitCode =
|
|
2754
|
+
process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
|
|
2519
2755
|
return;
|
|
2520
2756
|
}
|
|
2521
2757
|
if (classification === "inconclusive" && escalate) {
|
|
2522
2758
|
process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
|
|
2523
|
-
process.exitCode =
|
|
2759
|
+
process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
|
|
2524
2760
|
return;
|
|
2525
2761
|
}
|
|
2526
2762
|
if (classification === "inconclusive") {
|
|
@@ -2786,8 +3022,18 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2786
3022
|
// symlink/junction inside the dir, but the filter is cheap.
|
|
2787
3023
|
for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
|
|
2788
3024
|
const pbId = f.replace(/\.json$/, "");
|
|
2789
|
-
|
|
2790
|
-
|
|
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
|
+
);
|
|
2791
3037
|
}
|
|
2792
3038
|
const entryPath = path.resolve(path.join(resolvedDir, f));
|
|
2793
3039
|
if (!entryPath.startsWith(resolvedDir + path.sep)) {
|
|
@@ -2849,10 +3095,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2849
3095
|
|
|
2850
3096
|
const results = [];
|
|
2851
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
|
+
}
|
|
2852
3107
|
const pb = runner.loadPlaybook(id);
|
|
2853
3108
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
2854
3109
|
if (!directiveId) {
|
|
2855
|
-
results.push({ playbook_id: id, ok: false, error:
|
|
3110
|
+
results.push({ playbook_id: id, ok: false, error: `playbook ${id} has no directives` });
|
|
2856
3111
|
continue;
|
|
2857
3112
|
}
|
|
2858
3113
|
const submission = bundle[id] || {};
|
|
@@ -2903,8 +3158,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2903
3158
|
if (!persisted.ok) {
|
|
2904
3159
|
// Multi-run collision: don't abort the whole bundle; surface in the
|
|
2905
3160
|
// per-playbook result so the operator can see exactly which
|
|
2906
|
-
// 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.
|
|
2907
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
|
+
}
|
|
2908
3174
|
} else if (persisted.prior_session_id) {
|
|
2909
3175
|
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
2910
3176
|
}
|
|
@@ -2943,8 +3209,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2943
3209
|
// the body but exit code stayed 0 — CI gates couldn't distinguish "ran
|
|
2944
3210
|
// clean" from "blocked." v0.12.8: use exitCode (not process.exit()) so
|
|
2945
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);
|
|
2946
3221
|
const anyBlocked = results.some(r => r.ok === false);
|
|
2947
|
-
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; }
|
|
2948
3225
|
}
|
|
2949
3226
|
|
|
2950
3227
|
function cmdIngest(runner, args, runOpts, pretty) {
|
|
@@ -2971,11 +3248,12 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2971
3248
|
}
|
|
2972
3249
|
const playbookId = args.domain || submission.playbook_id || submission.domain;
|
|
2973
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;
|
|
2974
3252
|
const pb = runner.loadPlaybook(playbookId);
|
|
2975
3253
|
const directiveId = args.directive
|
|
2976
3254
|
|| submission.directive_id
|
|
2977
3255
|
|| (pb.directives[0] && pb.directives[0].id);
|
|
2978
|
-
if (!directiveId) return
|
|
3256
|
+
if (!directiveId) return refuseNoDirectives("ingest", playbookId, pretty);
|
|
2979
3257
|
|
|
2980
3258
|
// Strip the routing keys so the runner only sees the contract shape it expects.
|
|
2981
3259
|
const cleanedSubmission = {
|
|
@@ -3022,17 +3300,24 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
3022
3300
|
filename: "attestation.json",
|
|
3023
3301
|
});
|
|
3024
3302
|
if (!persisted.ok) {
|
|
3025
|
-
//
|
|
3026
|
-
//
|
|
3027
|
-
//
|
|
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).
|
|
3028
3307
|
const ctx = { session_id: result.session_id, existing_path: persisted.existingPath };
|
|
3029
3308
|
if (persisted.lock_contention) {
|
|
3030
3309
|
ctx.lock_contention = true;
|
|
3031
|
-
ctx.exit_code =
|
|
3032
|
-
|
|
3033
|
-
|
|
3310
|
+
ctx.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
3311
|
+
}
|
|
3312
|
+
if (persisted.storage_exhausted) {
|
|
3313
|
+
ctx.storage_exhausted = true;
|
|
3314
|
+
ctx.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
3034
3315
|
}
|
|
3035
|
-
|
|
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;
|
|
3036
3321
|
}
|
|
3037
3322
|
if (persisted.prior_session_id) {
|
|
3038
3323
|
result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
|
|
@@ -3204,6 +3489,22 @@ function persistAttestation(args) {
|
|
|
3204
3489
|
acquired = true;
|
|
3205
3490
|
break;
|
|
3206
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
|
+
}
|
|
3207
3508
|
if (lockErr.code !== "EEXIST" && lockErr.code !== "EPERM") throw lockErr;
|
|
3208
3509
|
let reclaimed = false;
|
|
3209
3510
|
try {
|
|
@@ -3244,13 +3545,13 @@ function persistAttestation(args) {
|
|
|
3244
3545
|
// already-non-zero value. Exit code 8 is reserved exclusively for
|
|
3245
3546
|
// LOCK_CONTENTION (attestation persist); see the exit-code table in
|
|
3246
3547
|
// printGlobalHelp().
|
|
3247
|
-
process.exitCode =
|
|
3548
|
+
process.exitCode = EXIT_CODES.LOCK_CONTENTION;
|
|
3248
3549
|
return {
|
|
3249
3550
|
ok: false,
|
|
3250
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.`,
|
|
3251
3552
|
existingPath: path.relative(process.cwd(), filePath),
|
|
3252
3553
|
lock_contention: true,
|
|
3253
|
-
exit_code:
|
|
3554
|
+
exit_code: EXIT_CODES.LOCK_CONTENTION,
|
|
3254
3555
|
};
|
|
3255
3556
|
}
|
|
3256
3557
|
try {
|
|
@@ -3273,6 +3574,19 @@ function persistAttestation(args) {
|
|
|
3273
3574
|
}
|
|
3274
3575
|
}
|
|
3275
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
|
+
}
|
|
3276
3590
|
return { ok: false, error: `Failed to write attestation: ${e.message}`, existingPath: null };
|
|
3277
3591
|
}
|
|
3278
3592
|
}
|
|
@@ -3384,9 +3698,13 @@ function maybeSignAttestation(filePath) {
|
|
|
3384
3698
|
* Validation regex + root-confinement check matches persistAttestation.
|
|
3385
3699
|
*/
|
|
3386
3700
|
function validateSessionIdForRead(sessionId) {
|
|
3387
|
-
|
|
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) {
|
|
3388
3706
|
throw new Error(
|
|
3389
|
-
`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}.`
|
|
3390
3708
|
);
|
|
3391
3709
|
}
|
|
3392
3710
|
return sessionId;
|
|
@@ -3644,7 +3962,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3644
3962
|
hint: "If you have inspected the attestation and the divergence is benign (e.g. you re-signed manually), pass --force-replay.",
|
|
3645
3963
|
};
|
|
3646
3964
|
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3647
|
-
process.exitCode =
|
|
3965
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
3648
3966
|
return;
|
|
3649
3967
|
}
|
|
3650
3968
|
if ((isSignedTamper || isClassTamper) && args["force-replay"]) {
|
|
@@ -3671,7 +3989,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3671
3989
|
hint: "If the sidecar was intentionally removed (e.g. a clean operator rotation) and you have inspected the attestation, pass --force-replay.",
|
|
3672
3990
|
};
|
|
3673
3991
|
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3674
|
-
process.exitCode =
|
|
3992
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
3675
3993
|
return;
|
|
3676
3994
|
} else if (!verify.signed && verify.reason && verify.reason.includes("no .sig sidecar") && args["force-replay"]) {
|
|
3677
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`);
|
|
@@ -3695,7 +4013,7 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3695
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.",
|
|
3696
4014
|
};
|
|
3697
4015
|
process.stderr.write(JSON.stringify(body) + "\n");
|
|
3698
|
-
process.exitCode =
|
|
4016
|
+
process.exitCode = EXIT_CODES.TAMPERED;
|
|
3699
4017
|
return;
|
|
3700
4018
|
} else if (!verify.signed && verify.reason && verify.reason.startsWith("attestation explicitly unsigned") && args["force-replay"]) {
|
|
3701
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`);
|
|
@@ -3725,16 +4043,32 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
3725
4043
|
// Fallback: synthesise pass-through preconditions from the playbook so the
|
|
3726
4044
|
// replay isn't blocked when the operator didn't originally pass them.
|
|
3727
4045
|
try {
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
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
|
+
}
|
|
3732
4057
|
} catch { /* ignore */ }
|
|
3733
4058
|
}
|
|
3734
4059
|
const replay = runner.run(prior.playbook_id, prior.directive_id, emptySubmission, replayOpts);
|
|
3735
4060
|
|
|
3736
4061
|
if (!replay || replay.ok === false) {
|
|
3737
|
-
|
|
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
|
+
);
|
|
3738
4072
|
}
|
|
3739
4073
|
|
|
3740
4074
|
const priorHash = prior.evidence_hash;
|
|
@@ -3916,7 +4250,11 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3916
4250
|
return cmdListAttestations(runner, args, runOpts, pretty);
|
|
3917
4251
|
}
|
|
3918
4252
|
if (!sessionId) {
|
|
3919
|
-
return emitError(
|
|
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
|
+
);
|
|
3920
4258
|
}
|
|
3921
4259
|
// Distinguish "validation rejected" from "valid format but not found".
|
|
3922
4260
|
// findSessionDir() returns null for BOTH (regex-rejected ids collapse to
|
|
@@ -3966,18 +4304,33 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
3966
4304
|
return emitError(`attest diff --against ${args.against}: no session dir found.`, null, pretty);
|
|
3967
4305
|
}
|
|
3968
4306
|
const otherFiles = fs.readdirSync(otherDir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
|
|
3969
|
-
//
|
|
3970
|
-
//
|
|
3971
|
-
//
|
|
3972
|
-
//
|
|
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.
|
|
3973
4315
|
let other = null;
|
|
3974
|
-
|
|
4316
|
+
const otherAttestationPath = path.join(otherDir, "attestation.json");
|
|
4317
|
+
if (fs.existsSync(otherAttestationPath)) {
|
|
3975
4318
|
try {
|
|
3976
|
-
const parsed = JSON.parse(fs.readFileSync(
|
|
3977
|
-
if (parsed && parsed.kind
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
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;
|
|
3981
4334
|
}
|
|
3982
4335
|
if (!other) {
|
|
3983
4336
|
return emitError(`attest diff --against ${args.against}: no attestations under that session id.`, null, pretty);
|
|
@@ -4042,15 +4395,14 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4042
4395
|
// tampered attestation.json and overwrote .sig with the unsigned stub).
|
|
4043
4396
|
const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
|
|
4044
4397
|
const hasPrivKey = fs.existsSync(privKeyPath);
|
|
4045
|
-
|
|
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) => {
|
|
4046
4404
|
const sigPath = path.join(dir, f + ".sig");
|
|
4047
4405
|
if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
4048
|
-
// wrap JSON.parse so a corrupt sidecar surfaces as a
|
|
4049
|
-
// structured tamper-class result (signed:false, verified:false,
|
|
4050
|
-
// tamper_class:"sidecar-corrupt") rather than throwing into the outer
|
|
4051
|
-
// dispatcher catch → exit 1. Pre-fix, a corrupt .sig produced a
|
|
4052
|
-
// generic exit-1 with no `results` array — operators piping through
|
|
4053
|
-
// `set -e` saw "command failed" with no tamper signal.
|
|
4054
4406
|
let sigDoc;
|
|
4055
4407
|
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
4056
4408
|
catch (e) {
|
|
@@ -4063,7 +4415,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4063
4415
|
};
|
|
4064
4416
|
}
|
|
4065
4417
|
if (sigDoc.algorithm === "unsigned") {
|
|
4066
|
-
// substitution detection.
|
|
4067
4418
|
if (hasPrivKey) {
|
|
4068
4419
|
return {
|
|
4069
4420
|
file: f,
|
|
@@ -4075,11 +4426,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4075
4426
|
}
|
|
4076
4427
|
return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
4077
4428
|
}
|
|
4078
|
-
// Strict algorithm check (mirrors verifyAttestationSidecar). Anything
|
|
4079
|
-
// that isn't exactly "Ed25519" or "unsigned" is refused as
|
|
4080
|
-
// tamper-class; null / "RSA-PSS" / arrays would otherwise fall through
|
|
4081
|
-
// to crypto.verify with Ed25519 defaults, producing either an opaque
|
|
4082
|
-
// verify-throw or a downgrade-bait acceptance path.
|
|
4083
4429
|
if (sigDoc.algorithm !== "Ed25519") {
|
|
4084
4430
|
return {
|
|
4085
4431
|
file: f,
|
|
@@ -4090,8 +4436,6 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4090
4436
|
};
|
|
4091
4437
|
}
|
|
4092
4438
|
if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
4093
|
-
// Normalize before crypto.verify — mirrors the signer path so the
|
|
4094
|
-
// verify pair is byte-stable across CRLF / BOM churn.
|
|
4095
4439
|
const rawContent = fs.readFileSync(path.join(dir, f), "utf8");
|
|
4096
4440
|
const content = normalizeAttestationBytes(rawContent);
|
|
4097
4441
|
try {
|
|
@@ -4102,30 +4446,58 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
4102
4446
|
} catch (e) {
|
|
4103
4447
|
return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
4104
4448
|
}
|
|
4105
|
-
}
|
|
4106
|
-
|
|
4107
|
-
//
|
|
4108
|
-
//
|
|
4109
|
-
//
|
|
4110
|
-
//
|
|
4111
|
-
//
|
|
4112
|
-
//
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4115
|
-
//
|
|
4116
|
-
const
|
|
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) =>
|
|
4117
4478
|
(r.signed && !r.verified)
|
|
4118
4479
|
|| r.tamper_class === "sidecar-corrupt"
|
|
4119
4480
|
|| r.tamper_class === "unsigned-substitution"
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
const body = {
|
|
4125
|
-
|
|
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) {
|
|
4126
4492
|
body.ok = false;
|
|
4127
4493
|
body.error = "attest verify: one or more attestations failed Ed25519 verification — possible post-hoc tampering";
|
|
4128
|
-
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"];
|
|
4129
4501
|
}
|
|
4130
4502
|
emit(body, pretty);
|
|
4131
4503
|
return;
|
|
@@ -4527,6 +4899,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4527
4899
|
const wantJson = !!args.json || !!args.pretty;
|
|
4528
4900
|
const indent = !!args.pretty;
|
|
4529
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
|
+
|
|
4530
4910
|
// Selective subchecks. If any of the four flags is passed, run only those.
|
|
4531
4911
|
// If none are passed, run all four plus signing-status.
|
|
4532
4912
|
const onlySigs = !!args.signatures;
|
|
@@ -4736,6 +5116,17 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4736
5116
|
// workflows aren't disturbed. Routed through a child process to keep
|
|
4737
5117
|
// cmdDoctor synchronous + bound the network timeout cleanly.
|
|
4738
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 {
|
|
4739
5130
|
try {
|
|
4740
5131
|
const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
|
|
4741
5132
|
const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
|
|
@@ -4763,6 +5154,7 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4763
5154
|
} catch (e) {
|
|
4764
5155
|
checks.registry = { ok: false, severity: "warn", error: e.message };
|
|
4765
5156
|
}
|
|
5157
|
+
}
|
|
4766
5158
|
}
|
|
4767
5159
|
|
|
4768
5160
|
// Walk every check and split: errors (severity error/missing/fail) vs warnings
|
|
@@ -4821,10 +5213,15 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
4821
5213
|
lines.push("exceptd doctor");
|
|
4822
5214
|
function mark(c, render) {
|
|
4823
5215
|
if (!c) return;
|
|
4824
|
-
//
|
|
4825
|
-
//
|
|
4826
|
-
//
|
|
4827
|
-
|
|
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]";
|
|
4828
5225
|
lines.push(` ${icon} ${render(c)}`);
|
|
4829
5226
|
}
|
|
4830
5227
|
mark(checks.signatures, c =>
|
|
@@ -5010,12 +5407,13 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5010
5407
|
if (!playbookId) {
|
|
5011
5408
|
return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
|
|
5012
5409
|
}
|
|
5410
|
+
if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
|
|
5013
5411
|
let pb;
|
|
5014
5412
|
try { pb = runner.loadPlaybook(playbookId); }
|
|
5015
5413
|
catch (e) { return emitError(`ai-run: ${e.message}`, { playbook: playbookId }, pretty); }
|
|
5016
5414
|
const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
|
|
5017
5415
|
if (!directiveId) {
|
|
5018
|
-
return
|
|
5416
|
+
return refuseNoDirectives("ai-run", playbookId, pretty);
|
|
5019
5417
|
}
|
|
5020
5418
|
|
|
5021
5419
|
// Compute the informational phases up front — both stream and no-stream
|
|
@@ -5109,7 +5507,11 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5109
5507
|
try {
|
|
5110
5508
|
result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
5111
5509
|
} catch (e) {
|
|
5112
|
-
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
|
+
);
|
|
5113
5515
|
}
|
|
5114
5516
|
if (!result || result.ok === false) {
|
|
5115
5517
|
// v0.12.12: same exit-after-write anti-pattern as the pre-stream
|
|
@@ -5147,21 +5549,26 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5147
5549
|
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
5148
5550
|
// Collision without --force-overwrite. AI agents typically pass
|
|
5149
5551
|
// unique session ids each run, so this path is rare but surface
|
|
5150
|
-
// it cleanly via the same JSONL contract.
|
|
5151
|
-
//
|
|
5152
|
-
//
|
|
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.
|
|
5153
5556
|
const eventBody = {
|
|
5154
5557
|
event: "error", reason: persistResult.error,
|
|
5155
5558
|
existing_attestation: persistResult.existingPath,
|
|
5156
5559
|
};
|
|
5157
5560
|
if (persistResult.lock_contention) {
|
|
5158
5561
|
eventBody.lock_contention = true;
|
|
5159
|
-
eventBody.exit_code =
|
|
5562
|
+
eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
5160
5563
|
}
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5564
|
+
if (persistResult.storage_exhausted) {
|
|
5565
|
+
eventBody.storage_exhausted = true;
|
|
5566
|
+
eventBody.exit_code = EXIT_CODES.STORAGE_EXHAUSTED;
|
|
5164
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;
|
|
5165
5572
|
return;
|
|
5166
5573
|
}
|
|
5167
5574
|
}
|
|
@@ -5265,12 +5672,18 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
5265
5672
|
existing_attestation: persistResult.existingPath };
|
|
5266
5673
|
if (persistResult.lock_contention) {
|
|
5267
5674
|
eventBody.lock_contention = true;
|
|
5268
|
-
eventBody.exit_code =
|
|
5675
|
+
eventBody.exit_code = EXIT_CODES.LOCK_CONTENTION;
|
|
5676
|
+
writeLine(eventBody);
|
|
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;
|
|
5269
5682
|
writeLine(eventBody);
|
|
5270
|
-
return finish(
|
|
5683
|
+
return finish(EXIT_CODES.STORAGE_EXHAUSTED);
|
|
5271
5684
|
}
|
|
5272
5685
|
writeLine(eventBody);
|
|
5273
|
-
return finish(
|
|
5686
|
+
return finish(EXIT_CODES.SESSION_ID_COLLISION);
|
|
5274
5687
|
}
|
|
5275
5688
|
}
|
|
5276
5689
|
writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
|
|
@@ -5743,9 +6156,14 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5743
6156
|
emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
|
|
5744
6157
|
} else if (fmt && fmt !== "json") {
|
|
5745
6158
|
// v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
|
|
5746
|
-
//
|
|
5747
|
-
|
|
5748
|
-
|
|
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
|
+
);
|
|
5749
6167
|
return;
|
|
5750
6168
|
} else {
|
|
5751
6169
|
emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
|
|
@@ -5768,8 +6186,8 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5768
6186
|
// actually evaluate signals, so it can't be a true detection.
|
|
5769
6187
|
if (summary.blocked > 0) {
|
|
5770
6188
|
const blockedReasons = failReasons.filter(r => r.includes("blocked"));
|
|
5771
|
-
process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit
|
|
5772
|
-
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;
|
|
5773
6191
|
return;
|
|
5774
6192
|
}
|
|
5775
6193
|
// Precedence: BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
|
|
@@ -5777,21 +6195,21 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
5777
6195
|
// result they want to see at the exit-code layer. Per-playbook detected
|
|
5778
6196
|
// findings remain in the body for them to investigate.
|
|
5779
6197
|
if (clockStartedFail) {
|
|
5780
|
-
process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit
|
|
5781
|
-
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;
|
|
5782
6200
|
return;
|
|
5783
6201
|
}
|
|
5784
6202
|
if (fail) {
|
|
5785
6203
|
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
5786
6204
|
// v0.11.11: exitCode + return so emit()'s stdout flushes.
|
|
5787
|
-
process.exitCode =
|
|
6205
|
+
process.exitCode = EXIT_CODES.DETECTED_ESCALATE;
|
|
5788
6206
|
return;
|
|
5789
6207
|
}
|
|
5790
6208
|
const suppliedEvidence = args.evidence || args["evidence-dir"];
|
|
5791
6209
|
const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
|
|
5792
6210
|
if (!suppliedEvidence && allInconclusive) {
|
|
5793
|
-
process.stderr.write(`[exceptd ci] WARN: no --evidence supplied and all ${summary.total} playbook(s) returned inconclusive. CI exit
|
|
5794
|
-
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;
|
|
5795
6213
|
}
|
|
5796
6214
|
}
|
|
5797
6215
|
|