@blamejs/exceptd-skills 0.12.13 → 0.12.16
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 +217 -0
- package/bin/exceptd.js +522 -27
- package/data/_indexes/_meta.json +45 -45
- package/data/_indexes/activity-feed.json +4 -4
- package/data/_indexes/catalog-summaries.json +29 -29
- package/data/_indexes/chains.json +3238 -3210
- package/data/_indexes/frequency.json +3 -0
- package/data/_indexes/jurisdiction-map.json +5 -3
- package/data/_indexes/section-offsets.json +712 -685
- package/data/_indexes/theater-fingerprints.json +1 -1
- package/data/_indexes/token-budget.json +355 -340
- package/data/atlas-ttps.json +144 -129
- package/data/attack-techniques.json +319 -76
- package/data/cve-catalog.json +516 -476
- package/data/cwe-catalog.json +1081 -759
- package/data/exploit-availability.json +63 -15
- package/data/framework-control-gaps.json +867 -843
- package/data/playbooks/ai-api.json +3 -1
- package/data/playbooks/containers.json +11 -3
- package/data/playbooks/cred-stores.json +3 -1
- package/data/playbooks/crypto-codebase.json +11 -11
- package/data/playbooks/crypto.json +1 -1
- package/data/playbooks/hardening.json +3 -1
- package/data/playbooks/kernel.json +3 -1
- package/data/playbooks/library-author.json +21 -10
- package/data/playbooks/mcp.json +1 -1
- package/data/playbooks/runtime.json +3 -1
- package/data/playbooks/sbom.json +2 -2
- package/data/playbooks/secrets.json +3 -1
- package/data/rfc-references.json +276 -276
- package/keys/EXPECTED_FINGERPRINT +1 -0
- package/lib/auto-discovery.js +57 -35
- package/lib/cross-ref-api.js +39 -6
- package/lib/cve-curation.js +33 -14
- package/lib/lint-skills.js +6 -1
- package/lib/playbook-runner.js +742 -78
- package/lib/prefetch.js +30 -8
- package/lib/refresh-external.js +40 -22
- package/lib/refresh-network.js +233 -17
- package/lib/scoring.js +191 -18
- package/lib/source-ghsa.js +219 -37
- package/lib/source-osv.js +381 -122
- package/lib/validate-catalog-meta.js +64 -9
- package/lib/validate-cve-catalog.js +56 -18
- package/lib/validate-indexes.js +88 -37
- package/lib/validate-playbooks.js +46 -0
- package/lib/verify.js +72 -0
- package/manifest-snapshot.json +1 -1
- package/manifest-snapshot.sha256 +1 -0
- package/manifest.json +73 -73
- package/orchestrator/dispatcher.js +21 -1
- package/orchestrator/event-bus.js +52 -8
- package/orchestrator/index.js +279 -20
- package/orchestrator/pipeline.js +63 -2
- package/orchestrator/scanner.js +32 -10
- package/orchestrator/scheduler.js +150 -17
- package/package.json +3 -1
- package/sbom.cdx.json +7 -7
- package/scripts/check-manifest-snapshot.js +32 -0
- package/scripts/check-sbom-currency.js +65 -3
- package/scripts/check-test-coverage.js +142 -19
- package/scripts/predeploy.js +83 -39
- package/scripts/refresh-manifest-snapshot.js +55 -4
- package/scripts/validate-vendor-online.js +169 -0
- package/scripts/verify-shipped-tarball.js +141 -9
- package/skills/ai-attack-surface/skill.md +18 -10
- package/skills/ai-c2-detection/skill.md +7 -2
- package/skills/ai-risk-management/skill.md +5 -4
- package/skills/api-security/skill.md +3 -3
- package/skills/attack-surface-pentest/skill.md +5 -5
- package/skills/cloud-security/skill.md +1 -1
- package/skills/compliance-theater/skill.md +8 -8
- package/skills/container-runtime-security/skill.md +1 -1
- package/skills/dlp-gap-analysis/skill.md +5 -1
- package/skills/email-security-anti-phishing/skill.md +1 -1
- package/skills/exploit-scoring/skill.md +18 -18
- package/skills/framework-gap-analysis/skill.md +6 -6
- package/skills/global-grc/skill.md +3 -2
- package/skills/identity-assurance/skill.md +2 -2
- package/skills/incident-response-playbook/skill.md +4 -4
- package/skills/kernel-lpe-triage/skill.md +21 -2
- package/skills/mcp-agent-trust/skill.md +17 -10
- package/skills/mlops-security/skill.md +2 -1
- package/skills/ot-ics-security/skill.md +1 -1
- package/skills/policy-exception-gen/skill.md +3 -3
- package/skills/pqc-first/skill.md +1 -1
- package/skills/rag-pipeline-security/skill.md +7 -3
- package/skills/researcher/skill.md +20 -3
- package/skills/sector-energy/skill.md +1 -1
- package/skills/sector-federal-government/skill.md +1 -1
- package/skills/sector-financial/skill.md +3 -3
- package/skills/sector-healthcare/skill.md +2 -2
- package/skills/security-maturity-tiers/skill.md +7 -7
- package/skills/skill-update-loop/skill.md +19 -3
- package/skills/supply-chain-integrity/skill.md +1 -1
- package/skills/threat-model-currency/skill.md +11 -11
- package/skills/threat-modeling-methodology/skill.md +3 -3
- package/skills/webapp-security/skill.md +1 -1
- package/skills/zeroday-gap-learn/skill.md +51 -7
- package/vendor/blamejs/_PROVENANCE.json +4 -1
- package/vendor/blamejs/worker-pool.js +38 -0
package/bin/exceptd.js
CHANGED
|
@@ -227,7 +227,8 @@ v0.12.0 canonical surface
|
|
|
227
227
|
for latest published version + days behind
|
|
228
228
|
|
|
229
229
|
ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
|
|
230
|
-
3 ran-but-no-evidence, 4 blocked (ok:false),
|
|
230
|
+
3 ran-but-no-evidence, 4 blocked (ok:false),
|
|
231
|
+
5 jurisdiction clock started, 1 framework error.
|
|
231
232
|
--all | --scope <type> | (auto-detect)
|
|
232
233
|
--max-rwep <n> cap below playbook default
|
|
233
234
|
--block-on-jurisdiction-clock
|
|
@@ -414,8 +415,21 @@ function main() {
|
|
|
414
415
|
if (cmd === "refresh" && (rest.includes("--no-network") || rest.includes("--prefetch"))) {
|
|
415
416
|
// v0.11.14 (#129): --prefetch is the operator-facing name for the
|
|
416
417
|
// cache-population path. --no-network retained as alias for back-compat.
|
|
418
|
+
//
|
|
419
|
+
// v0.12.16: BUT — `refresh --no-network` previously stripped BOTH flags
|
|
420
|
+
// before invoking prefetch.js, leaving prefetch in network-fetching
|
|
421
|
+
// (default) mode. The operator's "do not touch the network" intent was
|
|
422
|
+
// lost in dispatch. Ubuntu CI passed because cached data was warm;
|
|
423
|
+
// Windows + macOS CI runners with cold caches hit 30s test timeout
|
|
424
|
+
// attempting 47 real fetches. Preserve `--no-network` when the operator
|
|
425
|
+
// explicitly supplied it; strip only `--prefetch` (the alias).
|
|
417
426
|
effectiveCmd = "prefetch";
|
|
418
|
-
|
|
427
|
+
const wantedNoNetwork = rest.includes("--no-network");
|
|
428
|
+
effectiveRest = rest.filter(a => a !== "--prefetch");
|
|
429
|
+
if (wantedNoNetwork && !effectiveRest.includes("--no-network")) {
|
|
430
|
+
// Already preserved; no-op. But explicit so a future filter regression
|
|
431
|
+
// is visible.
|
|
432
|
+
}
|
|
419
433
|
} else if (cmd === "refresh" && rest.includes("--indexes-only")) {
|
|
420
434
|
effectiveCmd = "build-indexes";
|
|
421
435
|
effectiveRest = rest.filter(a => a !== "--indexes-only");
|
|
@@ -541,10 +555,16 @@ function emit(obj, pretty, humanRenderer) {
|
|
|
541
555
|
}
|
|
542
556
|
|
|
543
557
|
function emitError(msg, extra, pretty) {
|
|
558
|
+
// v0.12.14 (audit A P1-2): the v0.11.13 emit() fix used exitCode + return
|
|
559
|
+
// to defend stdout-buffered writes from truncation under piped consumers.
|
|
560
|
+
// emitError() (stderr) kept process.exit(1), which has the same truncation
|
|
561
|
+
// class — CLAUDE.md's "fix the class, not the instance." Now: write to
|
|
562
|
+
// stderr, set exitCode = 1, return. Every caller already uses
|
|
563
|
+
// `return emitError(...)` so the return-value propagation is clean.
|
|
544
564
|
const body = Object.assign({ ok: false, error: msg }, extra || {});
|
|
545
565
|
const s = pretty ? JSON.stringify(body, null, 2) : JSON.stringify(body);
|
|
546
566
|
process.stderr.write(s + "\n");
|
|
547
|
-
process.
|
|
567
|
+
process.exitCode = 1;
|
|
548
568
|
}
|
|
549
569
|
|
|
550
570
|
function readEvidence(evidenceFlag) {
|
|
@@ -572,6 +592,55 @@ function loadRunner() {
|
|
|
572
592
|
return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
|
|
573
593
|
}
|
|
574
594
|
|
|
595
|
+
/**
|
|
596
|
+
* F5: detect whether a parsed JSON document is plausibly CycloneDX VEX or
|
|
597
|
+
* OpenVEX. The runner's vexFilterFromDoc returns Set(0) tolerantly for
|
|
598
|
+
* anything else, which means an operator who passes SARIF / SBOM / CSAF /
|
|
599
|
+
* advisory JSON by mistake gets zero filter + zero feedback. We pre-validate
|
|
600
|
+
* at the CLI layer so the operator finds out at flag parse time.
|
|
601
|
+
*
|
|
602
|
+
* Returns { ok, detected, top_level_keys[] }. `detected` is one of:
|
|
603
|
+
* "cyclonedx-vex" | "openvex" | "not-vex"
|
|
604
|
+
*/
|
|
605
|
+
function detectVexShape(doc) {
|
|
606
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
607
|
+
return { ok: false, detected: "not-an-object", top_level_keys: [] };
|
|
608
|
+
}
|
|
609
|
+
const keys = Object.keys(doc);
|
|
610
|
+
// CycloneDX VEX: bomFormat==="CycloneDX" + vulnerabilities[] is the
|
|
611
|
+
// canonical shape; CycloneDX 1.4+ also allows a standalone vulnerabilities
|
|
612
|
+
// document where entries carry analysis.state. Accept either when the
|
|
613
|
+
// entries look vex-shaped (have id/bom_ref/analysis).
|
|
614
|
+
if (Array.isArray(doc.vulnerabilities)) {
|
|
615
|
+
const isBom = doc.bomFormat === "CycloneDX";
|
|
616
|
+
const entriesLookVex = doc.vulnerabilities.length === 0
|
|
617
|
+
|| doc.vulnerabilities.some(v => v && typeof v === "object" && (v.id || v["bom-ref"] || v.bom_ref || v.analysis));
|
|
618
|
+
if (isBom || entriesLookVex) {
|
|
619
|
+
return { ok: true, detected: "cyclonedx-vex", top_level_keys: keys };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// OpenVEX: @context starts with https://openvex.dev AND statements[]
|
|
623
|
+
const ctx = doc["@context"];
|
|
624
|
+
const ctxStr = Array.isArray(ctx) ? ctx[0] : ctx;
|
|
625
|
+
if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev") && Array.isArray(doc.statements)) {
|
|
626
|
+
return { ok: true, detected: "openvex", top_level_keys: keys };
|
|
627
|
+
}
|
|
628
|
+
// Common false-positive shapes — give the operator a hint.
|
|
629
|
+
if (Array.isArray(doc.runs) && doc.$schema && String(doc.$schema).includes("sarif")) {
|
|
630
|
+
return { ok: false, detected: "sarif-not-vex", top_level_keys: keys };
|
|
631
|
+
}
|
|
632
|
+
if (doc.document && doc.document.category && String(doc.document.category).startsWith("csaf_")) {
|
|
633
|
+
return { ok: false, detected: "csaf-advisory-not-vex", top_level_keys: keys };
|
|
634
|
+
}
|
|
635
|
+
if (doc.bomFormat === "CycloneDX" && !Array.isArray(doc.vulnerabilities)) {
|
|
636
|
+
return { ok: false, detected: "cyclonedx-sbom-without-vulnerabilities", top_level_keys: keys };
|
|
637
|
+
}
|
|
638
|
+
if (Array.isArray(doc.statements) && !ctxStr) {
|
|
639
|
+
return { ok: false, detected: "statements-array-but-no-openvex-context", top_level_keys: keys };
|
|
640
|
+
}
|
|
641
|
+
return { ok: false, detected: "unrecognized", top_level_keys: keys };
|
|
642
|
+
}
|
|
643
|
+
|
|
575
644
|
function firstDirectiveId(runner, playbookId) {
|
|
576
645
|
const pb = runner.loadPlaybook(playbookId);
|
|
577
646
|
if (!pb.directives || !pb.directives.length) {
|
|
@@ -592,6 +661,7 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
592
661
|
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
593
662
|
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
594
663
|
"force-overwrite", "no-stream", "block-on-jurisdiction-clock",
|
|
664
|
+
"force-replay",
|
|
595
665
|
"json-stdout-only", "fix", "human", "json", "strict-preconditions",
|
|
596
666
|
// v0.12.9: doctor --shipped-tarball runs the verify-shipped-tarball
|
|
597
667
|
// gate alongside --signatures. doctor --registry-check + --signatures
|
|
@@ -674,8 +744,42 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
674
744
|
}
|
|
675
745
|
// Multi-operator teams need attestations bound to a specific human or
|
|
676
746
|
// service identity. --operator <name> persists into the attestation file
|
|
677
|
-
// for audit-trail accountability.
|
|
678
|
-
|
|
747
|
+
// for audit-trail accountability.
|
|
748
|
+
//
|
|
749
|
+
// F9: validate the input. Pre-fix the value flowed into runOpts unchanged,
|
|
750
|
+
// so an operator could inject newlines / control chars / arbitrary length
|
|
751
|
+
// into attestation export output (multi-line "operator:" key/value pairs
|
|
752
|
+
// are a forgery surface — a forged second line could look like a separate
|
|
753
|
+
// attestation field to a naive parser). Now: strip ASCII control chars
|
|
754
|
+
// (\x00-\x1F + \x7F), cap length at 256, reject if all-whitespace.
|
|
755
|
+
if (args.operator !== undefined) {
|
|
756
|
+
if (typeof args.operator !== "string") {
|
|
757
|
+
return emitError("run: --operator must be a string.", { provided: typeof args.operator }, pretty);
|
|
758
|
+
}
|
|
759
|
+
// eslint-disable-next-line no-control-regex
|
|
760
|
+
if (/[\x00-\x1F\x7F]/.test(args.operator)) {
|
|
761
|
+
return emitError(
|
|
762
|
+
"run: --operator contains ASCII control characters (newline, tab, NUL, etc.). Refusing — these would corrupt attestation export shape and enable forgery via multi-line injection.",
|
|
763
|
+
{ provided_length: args.operator.length },
|
|
764
|
+
pretty
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (args.operator.length > 256) {
|
|
768
|
+
return emitError(
|
|
769
|
+
`run: --operator too long: ${args.operator.length} chars (limit 256). Use a stable identifier (email, service-account name) — not a free-form description.`,
|
|
770
|
+
{ provided_length: args.operator.length },
|
|
771
|
+
pretty
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
if (args.operator.trim().length === 0) {
|
|
775
|
+
return emitError(
|
|
776
|
+
"run: --operator is empty or whitespace-only. Pass a meaningful identifier or omit the flag.",
|
|
777
|
+
null,
|
|
778
|
+
pretty
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
runOpts.operator = args.operator;
|
|
782
|
+
}
|
|
679
783
|
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
680
784
|
// govern. Captured in attestation; downstream tooling can check whether
|
|
681
785
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
@@ -1071,8 +1175,11 @@ Exit codes:
|
|
|
1071
1175
|
3 Ran-but-no-evidence Every result was inconclusive AND no evidence was
|
|
1072
1176
|
submitted (visibility gap — CI should fail loud).
|
|
1073
1177
|
4 Blocked Result returned ok:false (preflight halt, missing
|
|
1074
|
-
preconditions with on_fail=halt, etc.)
|
|
1075
|
-
|
|
1178
|
+
preconditions with on_fail=halt, etc.).
|
|
1179
|
+
5 CLOCK_STARTED --block-on-jurisdiction-clock fired: at least one
|
|
1180
|
+
close.notification_actions entry started a
|
|
1181
|
+
regulatory clock (NIS2 24h, GDPR 72h, DORA 4h,
|
|
1182
|
+
etc.) and the operator has not acked.
|
|
1076
1183
|
|
|
1077
1184
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
1078
1185
|
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
@@ -1437,7 +1544,26 @@ function cmdPlan(runner, args, runOpts, pretty) {
|
|
|
1437
1544
|
emit(plan, pretty);
|
|
1438
1545
|
}
|
|
1439
1546
|
|
|
1547
|
+
// v0.12.15 (audit L F1, F2): --scope must validate against the accepted
|
|
1548
|
+
// set. The prior shape silently returned [] for any unknown scope, which
|
|
1549
|
+
// in `run --scope nonsense` produced `count: 0` + exit 0 (cmd reports
|
|
1550
|
+
// "ran 0 playbooks") and in `ci --scope nonsense` silently ran only the
|
|
1551
|
+
// cross-cutting set (the union with `framework` produced a false-positive
|
|
1552
|
+
// PASS). Both are operator-intent loss patterns CLAUDE.md flags as the
|
|
1553
|
+
// "field-present, content-wrong" class.
|
|
1554
|
+
const VALID_SCOPES = ["system", "code", "service", "cross-cutting", "all"];
|
|
1555
|
+
|
|
1556
|
+
function validateScopeOrThrow(scope) {
|
|
1557
|
+
if (typeof scope !== "string" || !VALID_SCOPES.includes(scope)) {
|
|
1558
|
+
throw new Error(
|
|
1559
|
+
`--scope must be one of ${JSON.stringify(VALID_SCOPES)}; got ${JSON.stringify(scope)}.`
|
|
1560
|
+
);
|
|
1561
|
+
}
|
|
1562
|
+
return scope;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1440
1565
|
function filterPlaybooksByScope(runner, scope) {
|
|
1566
|
+
validateScopeOrThrow(scope);
|
|
1441
1567
|
const ids = runner.listPlaybooks();
|
|
1442
1568
|
return ids.filter(id => {
|
|
1443
1569
|
try {
|
|
@@ -1513,7 +1639,8 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1513
1639
|
if (args.all) {
|
|
1514
1640
|
ids = runner.listPlaybooks();
|
|
1515
1641
|
} else {
|
|
1516
|
-
ids = filterPlaybooksByScope(runner, args.scope);
|
|
1642
|
+
try { ids = filterPlaybooksByScope(runner, args.scope); }
|
|
1643
|
+
catch (e) { return emitError(`run: ${e.message}`, { provided_scope: args.scope }, pretty); }
|
|
1517
1644
|
}
|
|
1518
1645
|
return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
|
|
1519
1646
|
}
|
|
@@ -1619,13 +1746,32 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1619
1746
|
// --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
|
|
1620
1747
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
1621
1748
|
if (args.vex) {
|
|
1749
|
+
let vexDoc;
|
|
1750
|
+
try {
|
|
1751
|
+
vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
|
|
1752
|
+
} catch (e) {
|
|
1753
|
+
return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1754
|
+
}
|
|
1755
|
+
// F5: validate the VEX shape BEFORE handing to runner.vexFilterFromDoc.
|
|
1756
|
+
// The runner tolerantly returns Set(0) for anything that's not CycloneDX
|
|
1757
|
+
// or OpenVEX shape, so an operator who passes a SARIF / SBOM / CSAF
|
|
1758
|
+
// advisory by mistake got ZERO filter applied and ZERO feedback. Now:
|
|
1759
|
+
// reject with a clear error naming the detected shape.
|
|
1760
|
+
const shape = detectVexShape(vexDoc);
|
|
1761
|
+
if (!shape.ok) {
|
|
1762
|
+
return emitError(
|
|
1763
|
+
`run: --vex file doesn't look like CycloneDX or OpenVEX. Detected shape: ${shape.detected}. ` +
|
|
1764
|
+
`Expected CycloneDX VEX (bomFormat:"CycloneDX" + vulnerabilities[]) or OpenVEX (@context starting "https://openvex.dev" + statements[]).`,
|
|
1765
|
+
{ provided_path: args.vex, top_level_keys: shape.top_level_keys },
|
|
1766
|
+
pretty
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1622
1769
|
try {
|
|
1623
|
-
const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
|
|
1624
1770
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
1625
1771
|
submission.signals = submission.signals || {};
|
|
1626
1772
|
submission.signals.vex_filter = [...vexSet];
|
|
1627
1773
|
} catch (e) {
|
|
1628
|
-
return emitError(`run: failed to
|
|
1774
|
+
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1629
1775
|
}
|
|
1630
1776
|
}
|
|
1631
1777
|
|
|
@@ -1693,8 +1839,11 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1693
1839
|
hint: "Pass --force-overwrite to replace, or supply a fresh --session-id (omit the flag for an auto-generated hex).",
|
|
1694
1840
|
verb: "run",
|
|
1695
1841
|
};
|
|
1842
|
+
// v0.12.14 (audit A P1-2): exitCode + return instead of process.exit
|
|
1843
|
+
// so the stderr line drains under piped CI consumers.
|
|
1696
1844
|
process.stderr.write(JSON.stringify(err) + "\n");
|
|
1697
|
-
process.
|
|
1845
|
+
process.exitCode = 3;
|
|
1846
|
+
return;
|
|
1698
1847
|
}
|
|
1699
1848
|
if (persistResult.prior_session_id) {
|
|
1700
1849
|
// Force-overwrite happened — surface the prior_session_id in the
|
|
@@ -1707,8 +1856,15 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1707
1856
|
}
|
|
1708
1857
|
|
|
1709
1858
|
if (result && result.ok === false) {
|
|
1859
|
+
// F19: align preflight-halt exit code between `run --ci` and `ci`.
|
|
1860
|
+
// Pre-fix `run --ci` exited 1 (FRAMEWORK_ERROR) while `ci` on the same
|
|
1861
|
+
// halt exited 4 (BLOCKED). Now both use 4 when --ci is in effect, so
|
|
1862
|
+
// operators can wire one set of exit-code expectations regardless of
|
|
1863
|
+
// which verb they call. Without --ci the legacy exit 1 is preserved
|
|
1864
|
+
// (ok:false bodies are framework signals when no CI gating is asked for).
|
|
1710
1865
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
1711
|
-
process.
|
|
1866
|
+
process.exitCode = args.ci ? 4 : 1;
|
|
1867
|
+
return;
|
|
1712
1868
|
}
|
|
1713
1869
|
|
|
1714
1870
|
// v0.11.6 (#96): --strict-preconditions escalates warn-level preflight
|
|
@@ -1757,6 +1913,26 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1757
1913
|
}
|
|
1758
1914
|
}
|
|
1759
1915
|
|
|
1916
|
+
// --block-on-jurisdiction-clock (F3): the flag was registered + documented on
|
|
1917
|
+
// `run --help` but only honored on cmdCi. Pre-fix, `exceptd run mcp
|
|
1918
|
+
// --block-on-jurisdiction-clock` exited 0 even when an NIS2 24h clock had
|
|
1919
|
+
// started. Now: when ANY close.notification_actions entry has a started
|
|
1920
|
+
// clock that the operator hasn't acked, exit 5 (CLOCK_STARTED) with a
|
|
1921
|
+
// stderr line naming the obligations. Mirrors cmdCi semantics.
|
|
1922
|
+
if (args["block-on-jurisdiction-clock"] && result && result.phases) {
|
|
1923
|
+
const startedClocks = (result.phases?.close?.notification_actions || [])
|
|
1924
|
+
.filter(n => n && n.clock_started_at != null && n.clock_pending_ack !== true);
|
|
1925
|
+
if (startedClocks.length > 0) {
|
|
1926
|
+
const refs = startedClocks
|
|
1927
|
+
.map(n => `${n.obligation_ref || n.jurisdiction || "?"}@${n.clock_started_at}`)
|
|
1928
|
+
.join("; ");
|
|
1929
|
+
process.stderr.write(`[exceptd run --block-on-jurisdiction-clock] CLOCK_STARTED: ${startedClocks.length} jurisdiction clock(s) running and unacked: ${refs}. Exit 5.\n`);
|
|
1930
|
+
emit(result, pretty);
|
|
1931
|
+
process.exitCode = 5;
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1760
1936
|
// --ci: machine-readable verdict for CI gates.
|
|
1761
1937
|
//
|
|
1762
1938
|
// The detect phase classification is the host-specific signal — "is THIS
|
|
@@ -1893,6 +2069,18 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1893
2069
|
const top = rwep?.threshold?.escalate ?? "n/a";
|
|
1894
2070
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
1895
2071
|
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`);
|
|
2072
|
+
// F11: surface --diff-from-latest verdict in the human renderer. Pre-fix
|
|
2073
|
+
// operators had to add --json to see whether the run drifted from the
|
|
2074
|
+
// previous attestation. Now one summary line follows the classification.
|
|
2075
|
+
if (obj.diff_from_latest) {
|
|
2076
|
+
const dfl = obj.diff_from_latest;
|
|
2077
|
+
if (dfl.status === "no_prior_attestation_for_playbook") {
|
|
2078
|
+
lines.push(`> drift vs prior: (no prior attestation for ${dfl.playbook_id})`);
|
|
2079
|
+
} else {
|
|
2080
|
+
const priorTag = dfl.prior_session_id ? ` (prior ${dfl.prior_session_id}` + (dfl.prior_captured_at ? ` @ ${dfl.prior_captured_at.slice(0, 19)})` : ")") : "";
|
|
2081
|
+
lines.push(`> drift vs prior: ${dfl.status}${priorTag}`);
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1896
2084
|
const cves = obj.phases?.analyze?.matched_cves || [];
|
|
1897
2085
|
const baseline = obj.phases?.analyze?.catalog_baseline_cves || [];
|
|
1898
2086
|
if (cves.length) {
|
|
@@ -1956,6 +2144,57 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1956
2144
|
* Falls back to running every playbook with empty evidence (engine returns
|
|
1957
2145
|
* inconclusive findings + visibility gaps) when no --evidence is given.
|
|
1958
2146
|
*/
|
|
2147
|
+
/**
|
|
2148
|
+
* F13: collapse per-playbook notification_actions into a deduped rollup.
|
|
2149
|
+
* Multi-playbook runs frequently surface the same jurisdiction clock from
|
|
2150
|
+
* 5-10 contributing playbooks (every EU-touching playbook starts a fresh
|
|
2151
|
+
* NIS2 Art.23 24h clock). Operators were drafting one notification per
|
|
2152
|
+
* entry instead of one per (jurisdiction, regulation, obligation, window).
|
|
2153
|
+
* Key tuple stays additive — every contributor playbook id lands in
|
|
2154
|
+
* `triggered_by_playbooks[]` — and earliest clock_started_at + deadline
|
|
2155
|
+
* win so the strictest deadline is what an operator sees.
|
|
2156
|
+
*/
|
|
2157
|
+
function buildJurisdictionClockRollup(results) {
|
|
2158
|
+
const m = new Map();
|
|
2159
|
+
for (const r of results || []) {
|
|
2160
|
+
if (!r || !r.phases) continue;
|
|
2161
|
+
const actions = r.phases?.close?.notification_actions || [];
|
|
2162
|
+
for (const n of actions) {
|
|
2163
|
+
if (!n || n.clock_started_at == null) continue;
|
|
2164
|
+
const key = [
|
|
2165
|
+
n.jurisdiction || "?",
|
|
2166
|
+
n.regulation || "?",
|
|
2167
|
+
n.obligation_ref || "?",
|
|
2168
|
+
String(n.window_hours ?? "?"),
|
|
2169
|
+
].join("::");
|
|
2170
|
+
const existing = m.get(key);
|
|
2171
|
+
if (existing) {
|
|
2172
|
+
if (!existing.triggered_by_playbooks.includes(r.playbook_id)) {
|
|
2173
|
+
existing.triggered_by_playbooks.push(r.playbook_id);
|
|
2174
|
+
}
|
|
2175
|
+
// Strictest (earliest) clock_started_at + deadline win.
|
|
2176
|
+
if ((n.clock_started_at || "") < (existing.clock_started_at || "")) {
|
|
2177
|
+
existing.clock_started_at = n.clock_started_at;
|
|
2178
|
+
}
|
|
2179
|
+
if (n.deadline && (!existing.deadline || n.deadline < existing.deadline)) {
|
|
2180
|
+
existing.deadline = n.deadline;
|
|
2181
|
+
}
|
|
2182
|
+
} else {
|
|
2183
|
+
m.set(key, {
|
|
2184
|
+
jurisdiction: n.jurisdiction || null,
|
|
2185
|
+
regulation: n.regulation || null,
|
|
2186
|
+
obligation_ref: n.obligation_ref || null,
|
|
2187
|
+
window_hours: n.window_hours ?? null,
|
|
2188
|
+
clock_started_at: n.clock_started_at,
|
|
2189
|
+
deadline: n.deadline || null,
|
|
2190
|
+
triggered_by_playbooks: [r.playbook_id],
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return [...m.values()];
|
|
2196
|
+
}
|
|
2197
|
+
|
|
1959
2198
|
function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
1960
2199
|
const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
|
|
1961
2200
|
runOpts.session_id = sessionId;
|
|
@@ -2040,6 +2279,16 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2040
2279
|
results.push(result);
|
|
2041
2280
|
}
|
|
2042
2281
|
|
|
2282
|
+
// F13: dedupe jurisdiction-clock notification actions across all playbook
|
|
2283
|
+
// results into a single rollup. Pre-fix a 13-playbook multi-run with 8
|
|
2284
|
+
// contributors of "EU NIS2 Art.23 24h" produced 8 separate entries, so
|
|
2285
|
+
// operators drafted 8 NIS2 notifications when one was sufficient. Per-
|
|
2286
|
+
// playbook entries are preserved on individual results; this rollup is
|
|
2287
|
+
// additive — keyed on (jurisdiction, regulation, obligation_ref,
|
|
2288
|
+
// window_hours) — with a triggered_by_playbooks[] list so operators see
|
|
2289
|
+
// which playbooks contributed.
|
|
2290
|
+
const jurisdictionClockRollup = buildJurisdictionClockRollup(results);
|
|
2291
|
+
|
|
2043
2292
|
emit({
|
|
2044
2293
|
ok: results.every(r => r.ok !== false),
|
|
2045
2294
|
session_id: sessionId,
|
|
@@ -2053,6 +2302,7 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2053
2302
|
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
2054
2303
|
inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
|
|
2055
2304
|
},
|
|
2305
|
+
jurisdiction_clock_rollup: jurisdictionClockRollup,
|
|
2056
2306
|
results,
|
|
2057
2307
|
}, pretty);
|
|
2058
2308
|
// v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
|
|
@@ -2068,6 +2318,13 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2068
2318
|
// `ingest` matches the AGENTS.md ingest contract. The submission JSON may
|
|
2069
2319
|
// carry playbook_id + directive_id; --domain/--directive flags override.
|
|
2070
2320
|
let submission = {};
|
|
2321
|
+
// F4: auto-detect piped stdin (parity with cmdRun). Without this,
|
|
2322
|
+
// `echo '{...}' | exceptd ingest` failed with "no playbook resolved"
|
|
2323
|
+
// because args.evidence stayed undefined and the routing JSON never got
|
|
2324
|
+
// read. Mirrors the cmdRun behavior at line 1614.
|
|
2325
|
+
if (!args.evidence && process.stdin.isTTY === false) {
|
|
2326
|
+
args.evidence = "-";
|
|
2327
|
+
}
|
|
2071
2328
|
if (args.evidence) {
|
|
2072
2329
|
try {
|
|
2073
2330
|
submission = readEvidence(args.evidence);
|
|
@@ -2347,12 +2604,43 @@ function maybeSignAttestation(filePath) {
|
|
|
2347
2604
|
* default root and the legacy cwd-relative root; returns whichever exists.
|
|
2348
2605
|
* Returns null if neither has the session.
|
|
2349
2606
|
*/
|
|
2607
|
+
/**
|
|
2608
|
+
* v0.12.14 (audit A P1-1): session-id validation — applied at every READ
|
|
2609
|
+
* site, not just writes. The write path (persistAttestation) was hardened
|
|
2610
|
+
* in v0.12.12, but the read paths (findSessionDir / cmdAttest / cmdReattest)
|
|
2611
|
+
* accepted arbitrary strings and joined them into path.join(root, id) with
|
|
2612
|
+
* no normalization. Reproducer that exfiltrated $HOME/.claude.json:
|
|
2613
|
+
* exceptd attest show '../../..'
|
|
2614
|
+
*
|
|
2615
|
+
* Validation regex + root-confinement check matches persistAttestation.
|
|
2616
|
+
*/
|
|
2617
|
+
function validateSessionIdForRead(sessionId) {
|
|
2618
|
+
if (typeof sessionId !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(sessionId)) {
|
|
2619
|
+
throw new Error(
|
|
2620
|
+
`Invalid session-id: ${JSON.stringify(sessionId).slice(0, 80)}. Must match /^[A-Za-z0-9._-]{1,64}$/.`
|
|
2621
|
+
);
|
|
2622
|
+
}
|
|
2623
|
+
return sessionId;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2350
2626
|
function findSessionDir(sessionId, runOpts) {
|
|
2627
|
+
// v0.12.14 (audit A P1-1): validate the session-id at every read path.
|
|
2628
|
+
try { validateSessionIdForRead(sessionId); }
|
|
2629
|
+
catch { return null; }
|
|
2351
2630
|
const candidates = [
|
|
2352
2631
|
path.join(resolveAttestationRoot(runOpts), sessionId),
|
|
2353
2632
|
path.join(process.cwd(), ".exceptd", "attestations", sessionId),
|
|
2354
2633
|
];
|
|
2355
|
-
for (const c of candidates)
|
|
2634
|
+
for (const c of candidates) {
|
|
2635
|
+
// Final-resolution check: the resolved candidate must stay strictly
|
|
2636
|
+
// inside its parent root after normalization. Defense in depth on top
|
|
2637
|
+
// of the regex check above — catches anything that survives the
|
|
2638
|
+
// string-level filter.
|
|
2639
|
+
const parent = path.dirname(c);
|
|
2640
|
+
const resolved = path.resolve(c);
|
|
2641
|
+
if (!resolved.startsWith(path.resolve(parent) + path.sep)) continue;
|
|
2642
|
+
if (fs.existsSync(c)) return c;
|
|
2643
|
+
}
|
|
2356
2644
|
return null;
|
|
2357
2645
|
}
|
|
2358
2646
|
|
|
@@ -2397,7 +2685,64 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
2397
2685
|
}
|
|
2398
2686
|
}
|
|
2399
2687
|
|
|
2688
|
+
/**
|
|
2689
|
+
* F10: factored Ed25519-sidecar verification used by both `attest verify`
|
|
2690
|
+
* and `reattest`. Returns { file, signed, verified, reason } for a given
|
|
2691
|
+
* attestation file path.
|
|
2692
|
+
*
|
|
2693
|
+
* Pre-fix, cmdReattest read attestation.json via JSON.parse with no
|
|
2694
|
+
* authenticity check. A tampered attestation was silently consumed and the
|
|
2695
|
+
* drift verdict was computed against forged input. Now cmdReattest calls
|
|
2696
|
+
* this and refuses on verify-fail unless --force-replay is set.
|
|
2697
|
+
*/
|
|
2698
|
+
function verifyAttestationSidecar(attFile) {
|
|
2699
|
+
const crypto = require("crypto");
|
|
2700
|
+
const sigPath = attFile + ".sig";
|
|
2701
|
+
const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
|
|
2702
|
+
const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
|
|
2703
|
+
if (!fs.existsSync(sigPath)) {
|
|
2704
|
+
return { file: attFile, signed: false, verified: false, reason: "no .sig sidecar" };
|
|
2705
|
+
}
|
|
2706
|
+
let sigDoc;
|
|
2707
|
+
try { sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8")); }
|
|
2708
|
+
catch (e) { return { file: attFile, signed: false, verified: false, reason: `sidecar parse error: ${e.message}` }; }
|
|
2709
|
+
if (sigDoc.algorithm === "unsigned") {
|
|
2710
|
+
return { file: attFile, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
|
|
2711
|
+
}
|
|
2712
|
+
if (!pubKey) {
|
|
2713
|
+
return { file: attFile, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
|
|
2714
|
+
}
|
|
2715
|
+
let content;
|
|
2716
|
+
try { content = fs.readFileSync(attFile, "utf8"); }
|
|
2717
|
+
catch (e) { return { file: attFile, signed: true, verified: false, reason: `attestation read error: ${e.message}` }; }
|
|
2718
|
+
try {
|
|
2719
|
+
const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
|
|
2720
|
+
key: pubKey, dsaEncoding: "ieee-p1363",
|
|
2721
|
+
}, Buffer.from(sigDoc.signature_base64, "base64"));
|
|
2722
|
+
return {
|
|
2723
|
+
file: attFile,
|
|
2724
|
+
signed: true,
|
|
2725
|
+
verified: !!ok,
|
|
2726
|
+
reason: ok ? "Ed25519 signature valid" : "Ed25519 signature INVALID — possible post-hoc tampering",
|
|
2727
|
+
};
|
|
2728
|
+
} catch (e) {
|
|
2729
|
+
return { file: attFile, signed: true, verified: false, reason: `verify error: ${e.message}` };
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2400
2733
|
function cmdReattest(runner, args, runOpts, pretty) {
|
|
2734
|
+
// F29: --since ISO-8601 validation parity with `attest list --since`
|
|
2735
|
+
// (already fixed in v0.12.12). Pre-fix, an invalid date silently passed
|
|
2736
|
+
// through to walkAttestationDir, where the lexical comparison either
|
|
2737
|
+
// matched all or none unpredictably.
|
|
2738
|
+
if (args.since != null) {
|
|
2739
|
+
if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
|
|
2740
|
+
return emitError(
|
|
2741
|
+
`reattest: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
|
|
2742
|
+
null, pretty
|
|
2743
|
+
);
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2401
2746
|
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
2402
2747
|
// without requiring the operator to know the session-id.
|
|
2403
2748
|
let sessionId = args._[0];
|
|
@@ -2417,6 +2762,37 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2417
2762
|
if (!fs.existsSync(attFile)) {
|
|
2418
2763
|
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
2419
2764
|
}
|
|
2765
|
+
|
|
2766
|
+
// F10: verify the .sig sidecar BEFORE consuming the prior attestation.
|
|
2767
|
+
// Pre-fix, a tampered attestation.json was silently parsed and the drift
|
|
2768
|
+
// verdict was computed against forged input. Now: refuse on verify-fail
|
|
2769
|
+
// with exit 6 (TAMPERED) unless --force-replay is explicitly set.
|
|
2770
|
+
// Unsigned attestations (no private key was available at run time) emit
|
|
2771
|
+
// a stderr warning but proceed — that's an operator config issue, not
|
|
2772
|
+
// tampering. `verified === false && signed === true` is the real tamper
|
|
2773
|
+
// signal.
|
|
2774
|
+
const verify = verifyAttestationSidecar(attFile);
|
|
2775
|
+
if (verify.signed && !verify.verified && !args["force-replay"]) {
|
|
2776
|
+
process.stderr.write(`[exceptd reattest] TAMPERED: attestation at ${attFile} failed Ed25519 verification (${verify.reason}). Refusing to replay against forged input. Pass --force-replay to override (the replay output records sidecar_verify so the audit trail captures the override).\n`);
|
|
2777
|
+
const body = {
|
|
2778
|
+
ok: false,
|
|
2779
|
+
error: `reattest: prior attestation failed signature verification — refusing to replay`,
|
|
2780
|
+
verb: "reattest",
|
|
2781
|
+
session_id: sessionId,
|
|
2782
|
+
attestation_file: attFile,
|
|
2783
|
+
sidecar_verify: verify,
|
|
2784
|
+
hint: "If you have inspected the attestation and the divergence is benign (e.g. you re-signed manually), pass --force-replay.",
|
|
2785
|
+
};
|
|
2786
|
+
process.stderr.write(JSON.stringify(body) + "\n");
|
|
2787
|
+
process.exitCode = 6;
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
if (verify.signed && !verify.verified && args["force-replay"]) {
|
|
2791
|
+
process.stderr.write(`[exceptd reattest] WARNING: --force-replay overriding failed signature verification on ${attFile} (${verify.reason}). The replay output records sidecar_verify so the override is audit-visible.\n`);
|
|
2792
|
+
} else if (!verify.signed && verify.reason !== "no .sig sidecar") {
|
|
2793
|
+
process.stderr.write(`[exceptd reattest] NOTE: attestation at ${attFile} has no Ed25519 signature (${verify.reason}). Proceeding — unsigned attestations are an operator config issue, not tamper evidence.\n`);
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2420
2796
|
let prior;
|
|
2421
2797
|
try {
|
|
2422
2798
|
prior = JSON.parse(fs.readFileSync(attFile, "utf8"));
|
|
@@ -2482,6 +2858,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2482
2858
|
replayed_at: new Date().toISOString(),
|
|
2483
2859
|
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
2484
2860
|
replay_rwep_adjusted: replay.phases && replay.phases.analyze && replay.phases.analyze.rwep && replay.phases.analyze.rwep.adjusted,
|
|
2861
|
+
// F10: persist the sidecar verify result + the force-replay flag so the
|
|
2862
|
+
// audit trail records whether the replay was authenticated input.
|
|
2863
|
+
sidecar_verify: verify,
|
|
2864
|
+
force_replay: !!args["force-replay"],
|
|
2485
2865
|
}, pretty);
|
|
2486
2866
|
}
|
|
2487
2867
|
|
|
@@ -3378,6 +3758,26 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
3378
3758
|
}
|
|
3379
3759
|
|
|
3380
3760
|
function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
3761
|
+
// v0.12.14 (audit A P2-3): --playbook is registered as `multi:` so
|
|
3762
|
+
// `--playbook a --playbook b` lands as an array. The prior filter used
|
|
3763
|
+
// strict equality (`j.playbook_id !== args.playbook`) — always false for
|
|
3764
|
+
// array, silently producing count: 0. Normalize to a Set up-front.
|
|
3765
|
+
const playbookFilter = (() => {
|
|
3766
|
+
if (args.playbook == null) return null;
|
|
3767
|
+
const list = Array.isArray(args.playbook) ? args.playbook : [args.playbook];
|
|
3768
|
+
return new Set(list.filter(x => typeof x === "string" && x.length > 0));
|
|
3769
|
+
})();
|
|
3770
|
+
// v0.12.14 (audit A P2-6): --since must be a parseable ISO-8601 timestamp.
|
|
3771
|
+
// Prior behavior silently accepted any string and lexically compared to
|
|
3772
|
+
// captured_at, producing 0-result or full-result depending on the string.
|
|
3773
|
+
if (args.since != null) {
|
|
3774
|
+
if (typeof args.since !== "string" || isNaN(Date.parse(args.since))) {
|
|
3775
|
+
return emitError(
|
|
3776
|
+
`attest list: --since must be a parseable ISO-8601 timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(args.since)).slice(0, 80)}`,
|
|
3777
|
+
null, pretty
|
|
3778
|
+
);
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3381
3781
|
// Enumerate sessions across both v0.11.0 default root and legacy cwd-
|
|
3382
3782
|
// relative root, so operators with prior attestations still see them.
|
|
3383
3783
|
const roots = [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")];
|
|
@@ -3395,7 +3795,8 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
3395
3795
|
for (const f of files) {
|
|
3396
3796
|
try {
|
|
3397
3797
|
const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
|
|
3398
|
-
|
|
3798
|
+
// v0.12.14: normalized array-set filter (see top of fn).
|
|
3799
|
+
if (playbookFilter && !playbookFilter.has(j.playbook_id)) continue;
|
|
3399
3800
|
if (args.since && (j.captured_at || "") < args.since) continue;
|
|
3400
3801
|
entries.push({
|
|
3401
3802
|
session_id: sid,
|
|
@@ -3415,7 +3816,7 @@ function cmdListAttestations(runner, args, runOpts, pretty) {
|
|
|
3415
3816
|
ok: true,
|
|
3416
3817
|
attestations: entries,
|
|
3417
3818
|
count: entries.length,
|
|
3418
|
-
filter: { playbook:
|
|
3819
|
+
filter: { playbook: playbookFilter ? [...playbookFilter] : null, since: args.since || null },
|
|
3419
3820
|
roots_searched: [...seenRoots],
|
|
3420
3821
|
}, pretty, (obj) => {
|
|
3421
3822
|
// v0.11.6 (#95) human renderer for attest list: one row per session.
|
|
@@ -3560,6 +3961,36 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3560
3961
|
process.exitCode = 1;
|
|
3561
3962
|
return;
|
|
3562
3963
|
}
|
|
3964
|
+
// v0.12.14 (audit A P2-1): ai-run --no-stream previously emitted a
|
|
3965
|
+
// session_id but never persisted the attestation, so the AI agent
|
|
3966
|
+
// calling ai-run couldn't chain into `attest show / verify / diff`
|
|
3967
|
+
// or `reattest` with the returned id. Now: same persistAttestation
|
|
3968
|
+
// shape as cmdRun, so AI-facing flow round-trips cleanly.
|
|
3969
|
+
if (result.session_id) {
|
|
3970
|
+
const persistResult = persistAttestation({
|
|
3971
|
+
sessionId: result.session_id,
|
|
3972
|
+
playbookId: result.playbook_id || playbookId,
|
|
3973
|
+
directiveId: result.directive_id || directiveId,
|
|
3974
|
+
evidenceHash: result.evidence_hash,
|
|
3975
|
+
operator: runOpts.operator,
|
|
3976
|
+
operatorConsent: runOpts.operator_consent,
|
|
3977
|
+
submission,
|
|
3978
|
+
runOpts,
|
|
3979
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
3980
|
+
filename: "attestation.json",
|
|
3981
|
+
});
|
|
3982
|
+
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
3983
|
+
// Collision without --force-overwrite. AI agents typically pass
|
|
3984
|
+
// unique session ids each run, so this path is rare but surface
|
|
3985
|
+
// it cleanly via the same JSONL contract.
|
|
3986
|
+
process.stdout.write(JSON.stringify({
|
|
3987
|
+
event: "error", reason: persistResult.error,
|
|
3988
|
+
existing_attestation: persistResult.existingPath,
|
|
3989
|
+
}) + "\n");
|
|
3990
|
+
process.exitCode = 3;
|
|
3991
|
+
return;
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3563
3994
|
// v0.11.8 (#101): unify ai-run --no-stream shape with `run`. Pre-0.11.8
|
|
3564
3995
|
// ai-run flattened phases to top-level (`govern`, `direct`, `look`, ...),
|
|
3565
3996
|
// while `run` nested them under `phases.*`. Operators writing JSONPath
|
|
@@ -3636,6 +4067,28 @@ function cmdAiRun(runner, args, runOpts, pretty) {
|
|
|
3636
4067
|
writeLine({ phase: "analyze", ...result.phases?.analyze });
|
|
3637
4068
|
writeLine({ phase: "validate", ...result.phases?.validate });
|
|
3638
4069
|
writeLine({ phase: "close", ...result.phases?.close });
|
|
4070
|
+
// v0.12.14 (audit A P2-1): persist the attestation in streaming mode
|
|
4071
|
+
// too. Without this, the session_id emitted in the `done` frame
|
|
4072
|
+
// can't be resolved by `attest show / verify / diff` or `reattest`.
|
|
4073
|
+
if (result.session_id) {
|
|
4074
|
+
const persistResult = persistAttestation({
|
|
4075
|
+
sessionId: result.session_id,
|
|
4076
|
+
playbookId: result.playbook_id || playbookId,
|
|
4077
|
+
directiveId: result.directive_id || directiveId,
|
|
4078
|
+
evidenceHash: result.evidence_hash,
|
|
4079
|
+
operator: runOpts.operator,
|
|
4080
|
+
operatorConsent: runOpts.operator_consent,
|
|
4081
|
+
submission,
|
|
4082
|
+
runOpts,
|
|
4083
|
+
forceOverwrite: !!args["force-overwrite"],
|
|
4084
|
+
filename: "attestation.json",
|
|
4085
|
+
});
|
|
4086
|
+
if (!persistResult.ok && !args["force-overwrite"]) {
|
|
4087
|
+
writeLine({ event: "error", reason: persistResult.error,
|
|
4088
|
+
existing_attestation: persistResult.existingPath });
|
|
4089
|
+
return finish(3);
|
|
4090
|
+
}
|
|
4091
|
+
}
|
|
3639
4092
|
writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
|
|
3640
4093
|
return finish(0);
|
|
3641
4094
|
};
|
|
@@ -3854,9 +4307,13 @@ function cmdAsk(runner, args, runOpts, pretty) {
|
|
|
3854
4307
|
* `run --all --ci` packaged as a verb so .github/workflows lines are short.
|
|
3855
4308
|
*
|
|
3856
4309
|
* Exit codes:
|
|
3857
|
-
* 0 PASS
|
|
3858
|
-
*
|
|
3859
|
-
*
|
|
4310
|
+
* 0 PASS — no detected findings, no rwep ≥ cap, no clock fired.
|
|
4311
|
+
* 2 FAIL — detected classification OR rwep ≥ cap.
|
|
4312
|
+
* 3 NO_EVIDENCE — every result inconclusive AND no --evidence supplied.
|
|
4313
|
+
* 4 BLOCKED — at least one playbook returned ok:false (preflight halt).
|
|
4314
|
+
* 5 CLOCK_STARTED — --block-on-jurisdiction-clock fired (F18); separated
|
|
4315
|
+
* from FAIL so operators distinguish "detected" from
|
|
4316
|
+
* "regulatory notification deadline running."
|
|
3860
4317
|
*/
|
|
3861
4318
|
function cmdCi(runner, args, runOpts, pretty) {
|
|
3862
4319
|
const scope = args.scope;
|
|
@@ -3879,7 +4336,8 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3879
4336
|
} else if (args.all) {
|
|
3880
4337
|
ids = runner.listPlaybooks();
|
|
3881
4338
|
} else if (scope) {
|
|
3882
|
-
ids = filterPlaybooksByScope(runner, scope);
|
|
4339
|
+
try { ids = filterPlaybooksByScope(runner, scope); }
|
|
4340
|
+
catch (e) { return emitError(`ci: ${e.message}`, { provided_scope: scope }, pretty); }
|
|
3883
4341
|
// Always include cross-cutting playbooks regardless of scope choice.
|
|
3884
4342
|
const cross = filterPlaybooksByScope(runner, "cross-cutting");
|
|
3885
4343
|
ids = [...new Set([...ids, ...cross])];
|
|
@@ -3927,6 +4385,11 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3927
4385
|
const results = [];
|
|
3928
4386
|
let fail = false;
|
|
3929
4387
|
let failReasons = [];
|
|
4388
|
+
// F18: track jurisdiction-clock signals separately from generic FAIL so the
|
|
4389
|
+
// exit code can distinguish "detected/escalated" (2) from "regulatory clock
|
|
4390
|
+
// running, operator must notify" (5). Pre-fix the two collapsed into exit 2.
|
|
4391
|
+
let clockStartedFail = false;
|
|
4392
|
+
let clockStartedReasons = [];
|
|
3930
4393
|
|
|
3931
4394
|
for (const id of ids) {
|
|
3932
4395
|
let pb;
|
|
@@ -3978,8 +4441,13 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3978
4441
|
failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
|
|
3979
4442
|
}
|
|
3980
4443
|
if (blockOnClock && clockStarted) {
|
|
3981
|
-
|
|
3982
|
-
|
|
4444
|
+
// F18: separate "clock started" from generic FAIL. Pre-fix this collapsed
|
|
4445
|
+
// into exit 2 (FAIL), so operators couldn't distinguish "playbook
|
|
4446
|
+
// detected" from "regulatory clock running." Tracked separately and
|
|
4447
|
+
// exit 5 (CLOCK_STARTED) is selected below, taking precedence over
|
|
4448
|
+
// FAIL but not BLOCKED.
|
|
4449
|
+
clockStartedFail = true;
|
|
4450
|
+
clockStartedReasons.push(`${id}: jurisdiction clock started`);
|
|
3983
4451
|
}
|
|
3984
4452
|
}
|
|
3985
4453
|
|
|
@@ -3997,13 +4465,22 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3997
4465
|
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
3998
4466
|
const totalForVerdict = results.length;
|
|
3999
4467
|
const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
|
|
4468
|
+
// F18: precedence — BLOCKED > CLOCK_STARTED > FAIL > NO_EVIDENCE > PASS.
|
|
4469
|
+
// CLOCK_STARTED outranks FAIL because the operator explicitly opted into
|
|
4470
|
+
// the clock gate (--block-on-jurisdiction-clock); when that gate fires,
|
|
4471
|
+
// they want the regulatory-deadline signal even if a detected finding
|
|
4472
|
+
// also surfaces. (A detected finding is still in the body for the
|
|
4473
|
+
// operator to act on; the exit-code dimension just answers "what's the
|
|
4474
|
+
// top-line reason this gate failed.")
|
|
4000
4475
|
const computedVerdict = blockedCount > 0
|
|
4001
4476
|
? "BLOCKED"
|
|
4002
|
-
:
|
|
4003
|
-
? "
|
|
4004
|
-
:
|
|
4005
|
-
? "
|
|
4006
|
-
:
|
|
4477
|
+
: clockStartedFail
|
|
4478
|
+
? "CLOCK_STARTED"
|
|
4479
|
+
: fail
|
|
4480
|
+
? "FAIL"
|
|
4481
|
+
: noEvidenceAllInconclusive
|
|
4482
|
+
? "NO_EVIDENCE"
|
|
4483
|
+
: "PASS";
|
|
4007
4484
|
|
|
4008
4485
|
// v0.12.9 (P2 #8 from production smoke): roll up per-playbook framework_gap
|
|
4009
4486
|
// mappings to the ci top-level. Phase 7 of the seven-phase contract surfaces
|
|
@@ -4044,8 +4521,15 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4044
4521
|
.filter(n => n && n.clock_started_at != null).length,
|
|
4045
4522
|
framework_gap_rollup: frameworkGapRollup,
|
|
4046
4523
|
framework_gap_count: frameworkGapRollup.length,
|
|
4524
|
+
// F13: dedupe jurisdiction-clock notifications across playbooks; see
|
|
4525
|
+
// buildJurisdictionClockRollup. Multi-playbook ci runs were producing
|
|
4526
|
+
// one notification entry per contributing playbook (often 8+) when a
|
|
4527
|
+
// single notification per (jurisdiction, regulation, obligation,
|
|
4528
|
+
// window) was the right shape.
|
|
4529
|
+
jurisdiction_clock_rollup: buildJurisdictionClockRollup(results),
|
|
4047
4530
|
verdict: computedVerdict,
|
|
4048
4531
|
fail_reasons: failReasons,
|
|
4532
|
+
clock_started_reasons: clockStartedReasons,
|
|
4049
4533
|
};
|
|
4050
4534
|
|
|
4051
4535
|
// v0.11.4 (#72): ci --format <fmt> previously emitted the full bundle
|
|
@@ -4076,8 +4560,10 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4076
4560
|
emit({ verb: "ci", session_id: sessionId, format: fmt, bundles_count: bundles.length, bundles }, pretty);
|
|
4077
4561
|
} else if (fmt && fmt !== "json") {
|
|
4078
4562
|
// v0.11.4 (#76): garbage format rejected with structured error, not silent empty stdout.
|
|
4563
|
+
// v0.12.14: exitCode + return; matches the emitError class fix.
|
|
4079
4564
|
process.stderr.write(JSON.stringify({ ok: false, error: `ci: --format "${fmt}" not in accepted set ["summary","markdown","csaf-2.0","sarif","openvex","json"].`, verb: "ci" }) + "\n");
|
|
4080
|
-
process.
|
|
4565
|
+
process.exitCode = 2;
|
|
4566
|
+
return;
|
|
4081
4567
|
} else {
|
|
4082
4568
|
emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
|
|
4083
4569
|
}
|
|
@@ -4103,6 +4589,15 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4103
4589
|
process.exitCode = 4;
|
|
4104
4590
|
return;
|
|
4105
4591
|
}
|
|
4592
|
+
// F18: precedence BLOCKED > CLOCK_STARTED > FAIL. The operator opted into
|
|
4593
|
+
// --block-on-jurisdiction-clock; when a clock fires, that's the gate
|
|
4594
|
+
// result they want to see at the exit-code layer. Per-playbook detected
|
|
4595
|
+
// findings remain in the body for them to investigate.
|
|
4596
|
+
if (clockStartedFail) {
|
|
4597
|
+
process.stderr.write(`[exceptd ci] CLOCK_STARTED: ${clockStartedReasons.join("; ")}. Exit 5.\n`);
|
|
4598
|
+
process.exitCode = 5;
|
|
4599
|
+
return;
|
|
4600
|
+
}
|
|
4106
4601
|
if (fail) {
|
|
4107
4602
|
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
4108
4603
|
// v0.11.11: exitCode + return so emit()'s stdout flushes.
|