@blamejs/exceptd-skills 0.12.15 → 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 +67 -0
- package/bin/exceptd.js +377 -20
- package/data/_indexes/_meta.json +3 -3
- package/data/cve-catalog.json +1 -1
- 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/lib/auto-discovery.js +36 -31
- package/lib/cve-curation.js +15 -9
- package/lib/prefetch.js +30 -8
- package/lib/refresh-network.js +40 -0
- package/lib/scoring.js +171 -11
- package/lib/validate-playbooks.js +46 -0
- package/manifest.json +39 -39
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
- package/scripts/verify-shipped-tarball.js +35 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.16 — 2026-05-14
|
|
4
|
+
|
|
5
|
+
**Patch: highest-impact P1 security findings from the v0.12.15 audit pile.**
|
|
6
|
+
|
|
7
|
+
### Sign/verify trust chain (audit I)
|
|
8
|
+
|
|
9
|
+
- **CRLF/BOM bypass on the shipped-tarball verify gate closed.** `scripts/verify-shipped-tarball.js` previously read raw on-disk bytes and called `crypto.verify` directly — bypassing the CRLF/BOM normalization that `lib/sign.js` + `lib/verify.js` apply on both sides of the byte-stability contract. The gate's whole purpose is to catch the v0.11.x signature regression class; without the same normalization, it would itself report 0/38 on any tree where line-ending normalization touched the source between sign and pack (a Windows contributor with `core.autocrlf=true`, or any tool like Prettier in the CI pipeline). The `normalizeSkillBytes` helper is now mirrored in this fourth normalize() implementation.
|
|
10
|
+
- **`keys/EXPECTED_FINGERPRINT` pin now consulted at every public-key load site.** Previously only `lib/verify.js` + `scripts/verify-shipped-tarball.js` checked the pin. `lib/refresh-network.js` and `bin/exceptd.js attest verify` both loaded `keys/public.pem` and trusted it without the cross-check. A coordinated attacker who tampered with `keys/public.pem` on the operator's host (e.g. via a prior compromised refresh) passed every check because the local↔tarball fingerprints matched each other. Now the pin is the external trust anchor at all four load sites. Honors `KEYS_ROTATED=1` env to allow legitimate rotation without re-bootstrap; missing pin file degrades to warn-and-continue.
|
|
11
|
+
|
|
12
|
+
### CI workflow security (audit N)
|
|
13
|
+
|
|
14
|
+
- **`atlas-currency.yml` script-injection sink closed (CWE-1395).** `${{ steps.currency.outputs.report }}` was interpolated directly into a github-script template literal; the `report` value is unescaped output of `node orchestrator/index.js currency`. A skill author who landed a string containing a backtick followed by `${process.exit(0)}` (or worse, an exfil to a webhook with `${process.env.GITHUB_TOKEN}`) got arbitrary JS execution inside the github-script runtime with the workflow's token. Now routed via `env.REPORT_TEXT` and read inside the script body as `process.env.REPORT_TEXT`.
|
|
15
|
+
- **`refresh.yml` shell-injection from `workflow_dispatch` input closed (CWE-78).** `${{ inputs.source }}` was interpolated directly into a bash `run:` block. An operator passing `kev; rm -rf /; #` got shell injection inside the runner. Now routed via `env.SOURCE_INPUT` and validated against `^[a-z,]+$` (the documented `kev,epss,nvd,rfc,pins` allowlist shape) before passing to the CLI.
|
|
16
|
+
- `actions/checkout` SHA comments aligned across `ci.yml`/`release.yml`/`scorecard.yml` (no SHA change; comment-only).
|
|
17
|
+
- `secret-scan` job declares explicit `permissions: contents: read` (survives a future repo visibility flip).
|
|
18
|
+
- `gitleaks` resolver now has a hardcoded fallback version + non-fatal failure path so a GitHub API HTML-error response doesn't block every CI run.
|
|
19
|
+
- New `tests/workflows-security.test.js` enforces: no `${{ steps.*.outputs.* }}` inside github-script template literals; no `${{ inputs.* }}` inside bash `run:` blocks; every third-party action is SHA-pinned; every workflow declares `permissions:`.
|
|
20
|
+
|
|
21
|
+
### CLI hardening (audit L)
|
|
22
|
+
|
|
23
|
+
- **`--block-on-jurisdiction-clock` now honored on `cmdRun`.** Previously the flag was registered + documented but only `cmdCi` consumed it; `run --block-on-jurisdiction-clock` exited 0 even when an NIS2 24h clock had started. Now both verbs exit 5 (`CLOCK_STARTED`) when any notification action has a non-null `clock_started_at` and an unacked operator consent.
|
|
24
|
+
- **`cmdIngest` auto-detects piped stdin.** Mirrors the `cmdRun` shape — `echo '{...}' | exceptd ingest` now works without an explicit `--evidence -`.
|
|
25
|
+
- **`--vex` validates document shape before applying.** Previously any malformed JSON (SARIF, SBOM, CSAF advisory by mistake) resulted in a silent empty filter; now CycloneDX (`vulnerabilities[]` or `bomFormat: 'CycloneDX'`) or OpenVEX (`statements[]` + `@context` on openvex.dev) shape required before the filter is consumed.
|
|
26
|
+
- **`cmdReattest` verifies the `.sig` sidecar** before consuming the prior attestation. A tampered attestation is no longer silently consumed for the drift verdict. `--force-replay` available for legitimate ack-of-divergence.
|
|
27
|
+
- **`--operator <name>` validated**: rejects ASCII control chars + newlines; caps length at 256; rejects all-whitespace. Closes the "multi-line operator forgery" surface in CSAF / attest export rendering.
|
|
28
|
+
- **`--diff-from-latest` result surfaced in human renderer**: operators running with `--diff-from-latest` and no `--json` now see a `> drift vs prior: <status>` line.
|
|
29
|
+
- **Cross-playbook jurisdiction clock rollup** in `cmdRunMulti` / `cmdCi`: deduped by `(jurisdiction, regulation, obligation, window_hours)`, `triggered_by_playbooks[]` lists contributors. Operators running 13 playbooks no longer draft 8 separate NIS2 24h notifications.
|
|
30
|
+
- `--block-on-jurisdiction-clock` exit code split from `FAIL` (exit 2) → `CLOCK_STARTED` (exit 5). CI gates can distinguish "detected" from "clock fired".
|
|
31
|
+
- `cmdReattest --since` validated as parseable ISO-8601.
|
|
32
|
+
|
|
33
|
+
### Scoring math hardening (audit J)
|
|
34
|
+
|
|
35
|
+
- `scoreCustom` now treats `active_exploitation: 'unknown'` as `0.25 × weight` (was 0) — aligning with `playbook-runner._activeExploitationLadder` semantics so catalog-side and runtime-side scoring agree.
|
|
36
|
+
- New `deriveRwepFromFactors(factors)` helper exported; detects whether `rwep_factors` is in Shape A (boolean inputs to `scoreCustom`) or Shape B (numeric weighted contributions) and produces a consistent score. Documents the dual-semantics so the rename can land cleanly in v0.13.0.
|
|
37
|
+
- `validateFactors` NaN/Infinity diagnostics now use `Number.isFinite` with dedicated messages (was misleading "expected number, got number (null)").
|
|
38
|
+
- `validateFactors` flags unknown factor keys ("unknown factor: X (ignored)").
|
|
39
|
+
- `scoreCustom(factors, {collectWarnings: true})` returns `_rwep_raw_unclamped` so operators see deduction magnitude even when the floor clamp absorbs negative weights.
|
|
40
|
+
- `compare()` "broadly aligned" band tightened from ±20 to ±10. The Copy Fail RWEP-vs-CVSS divergence (delta 12) now correctly surfaces as "significantly higher than CVSS equivalent."
|
|
41
|
+
- `Math.floor(20/2)` arithmetic replaced with `RWEP_WEIGHTS.active_exploitation * 0.5` (no behavior change today; closes a future odd-weight asymmetry).
|
|
42
|
+
|
|
43
|
+
### Curation + auto-discovery + prefetch (audit M)
|
|
44
|
+
|
|
45
|
+
- **Hidden second scoring path in `lib/cve-curation.js` closed.** The apply path previously derived `rwep_score` via `Object.values(rwep_factors).reduce(sum, 0)` — bypassing `scoring.js` entirely. Replaced with `deriveRwepFromFactors()`.
|
|
46
|
+
- **Auto-discovery RWEP divergence closed.** `lib/auto-discovery.js` previously stored `rwep_factors` with null values for poc_available/ai_*/reboot_required while calling `scoreCustom` with `true` defaults; stored factors and stored score were inconsistent and `scoring.validate()` always flagged it. New `buildScoringInputs(kev, nvd)` is the single source of truth.
|
|
47
|
+
- **`lib/prefetch.js` GITHUB_TOKEN now reaches the request.** The auth lookup keyed off source name `"github"` but the registered source is `"pins"` — anonymous rate-limit applied even when `GITHUB_TOKEN` was set. Fixed.
|
|
48
|
+
- **`lib/prefetch.js` docs corrected**: header comment + `printHelp()` no longer reference non-existent source names `ietf` and `github`.
|
|
49
|
+
- **`readCached` no longer returns stale data as fresh** when `fetched_at` is missing/corrupt (the `NaN > maxAgeMs === false` short-circuit was treating undefined-age entries as eternally-fresh).
|
|
50
|
+
|
|
51
|
+
### Playbook quality (audit K)
|
|
52
|
+
|
|
53
|
+
- **Mutex reciprocity validator** in `lib/validate-playbooks.js`: walks every `_meta.mutex` entry, emits WARNING per asymmetric edge. Reciprocity backfilled across 7 mutex relationships (secrets↔library-author, kernel↔hardening, containers↔library-author, etc.).
|
|
54
|
+
- **`containers → sbom` feeds_into edge** added (container-image-layer SBOM matching against KEV-listed CVEs is a primary v0.12.x use case but wasn't declared).
|
|
55
|
+
- **Domain CVE refs backfilled** where threat_context cited CVEs without referencing them: `runtime.cve_refs += CVE-2026-31431`, `ai-api.cve_refs += CVE-2026-30615`. `containers` threat_context's stale `CVE-2024-21626` (not in catalog) stripped.
|
|
56
|
+
- **ATLAS refs backfilled**: `cred-stores.atlas_refs += AML.T0055` (Unsecured Credentials), `containers.atlas_refs += AML.T0010` (ML Supply Chain).
|
|
57
|
+
- **Artifact type enum drift normalized**: 19 occurrences across crypto-codebase / crypto / library-author / mcp / sbom of `"file_path"` and `"log_pattern"` rewritten to the schema enum (`"file"` / `"log"`).
|
|
58
|
+
- **Indicator type enum drift normalized**: 3 occurrences in `library-author` of `"api_response"` rewritten to `"api_call_sequence"`.
|
|
59
|
+
- **FP-check backfill** on library-author indicators (publish-workflow-action-refs-mutable + tag-protection-absent) — gold-standard pattern from `gha-workflow-script-injection-sink` extended to two more high-confidence indicators.
|
|
60
|
+
|
|
61
|
+
### Repository
|
|
62
|
+
|
|
63
|
+
- `data/cve-catalog.json` synthetic test-pollution entry (`CVE-9999-99999`) removed (left by a test run that used the real catalog path).
|
|
64
|
+
- 29 new RWEP vector regression tests in `tests/scoring-vectors.test.js`.
|
|
65
|
+
- 8 new workflow-security regression tests in `tests/workflows-security.test.js`.
|
|
66
|
+
- `validate-playbooks.js` now reports 12/13 PASS + 1 WARN (was 8 PASS + 5 WARN before normalization).
|
|
67
|
+
|
|
68
|
+
Test count: 701 → 738 (+37: 29 scoring vectors + 8 workflow-security). Predeploy gates: 14/14. Skills: 38/38 signed and verified.
|
|
69
|
+
|
|
3
70
|
## 0.12.15 — 2026-05-14
|
|
4
71
|
|
|
5
72
|
**Patch: e2e RWEP factor-scaling fix + audit-surfaced silent-disable regressions. v0.12.14 publish payload.**
|
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");
|
|
@@ -578,6 +592,55 @@ function loadRunner() {
|
|
|
578
592
|
return require(path.join(PKG_ROOT, "lib", "playbook-runner.js"));
|
|
579
593
|
}
|
|
580
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
|
+
|
|
581
644
|
function firstDirectiveId(runner, playbookId) {
|
|
582
645
|
const pb = runner.loadPlaybook(playbookId);
|
|
583
646
|
if (!pb.directives || !pb.directives.length) {
|
|
@@ -598,6 +661,7 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
598
661
|
bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
|
|
599
662
|
"ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
|
|
600
663
|
"force-overwrite", "no-stream", "block-on-jurisdiction-clock",
|
|
664
|
+
"force-replay",
|
|
601
665
|
"json-stdout-only", "fix", "human", "json", "strict-preconditions",
|
|
602
666
|
// v0.12.9: doctor --shipped-tarball runs the verify-shipped-tarball
|
|
603
667
|
// gate alongside --signatures. doctor --registry-check + --signatures
|
|
@@ -680,8 +744,42 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
680
744
|
}
|
|
681
745
|
// Multi-operator teams need attestations bound to a specific human or
|
|
682
746
|
// service identity. --operator <name> persists into the attestation file
|
|
683
|
-
// for audit-trail accountability.
|
|
684
|
-
|
|
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
|
+
}
|
|
685
783
|
// --ack: operator acknowledges the jurisdiction obligations surfaced by
|
|
686
784
|
// govern. Captured in attestation; downstream tooling can check whether
|
|
687
785
|
// consent was explicit vs. implicit. AGENTS.md says the AI should surface
|
|
@@ -1077,8 +1175,11 @@ Exit codes:
|
|
|
1077
1175
|
3 Ran-but-no-evidence Every result was inconclusive AND no evidence was
|
|
1078
1176
|
submitted (visibility gap — CI should fail loud).
|
|
1079
1177
|
4 Blocked Result returned ok:false (preflight halt, missing
|
|
1080
|
-
preconditions with on_fail=halt, etc.)
|
|
1081
|
-
|
|
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.
|
|
1082
1183
|
|
|
1083
1184
|
Output: verb, session_id, playbooks_run, summary{total, detected,
|
|
1084
1185
|
max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
|
|
@@ -1645,13 +1746,32 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1645
1746
|
// --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
|
|
1646
1747
|
// CVE ID set through to analyze() so matched_cves drops them.
|
|
1647
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
|
+
}
|
|
1648
1769
|
try {
|
|
1649
|
-
const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
|
|
1650
1770
|
const vexSet = runner.vexFilterFromDoc(vexDoc);
|
|
1651
1771
|
submission.signals = submission.signals || {};
|
|
1652
1772
|
submission.signals.vex_filter = [...vexSet];
|
|
1653
1773
|
} catch (e) {
|
|
1654
|
-
return emitError(`run: failed to
|
|
1774
|
+
return emitError(`run: failed to apply --vex ${args.vex}: ${e.message}`, null, pretty);
|
|
1655
1775
|
}
|
|
1656
1776
|
}
|
|
1657
1777
|
|
|
@@ -1736,9 +1856,14 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1736
1856
|
}
|
|
1737
1857
|
|
|
1738
1858
|
if (result && result.ok === false) {
|
|
1739
|
-
//
|
|
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).
|
|
1740
1865
|
process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
|
|
1741
|
-
process.exitCode = 1;
|
|
1866
|
+
process.exitCode = args.ci ? 4 : 1;
|
|
1742
1867
|
return;
|
|
1743
1868
|
}
|
|
1744
1869
|
|
|
@@ -1788,6 +1913,26 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1788
1913
|
}
|
|
1789
1914
|
}
|
|
1790
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
|
+
|
|
1791
1936
|
// --ci: machine-readable verdict for CI gates.
|
|
1792
1937
|
//
|
|
1793
1938
|
// The detect phase classification is the host-specific signal — "is THIS
|
|
@@ -1924,6 +2069,18 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1924
2069
|
const top = rwep?.threshold?.escalate ?? "n/a";
|
|
1925
2070
|
const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
|
|
1926
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
|
+
}
|
|
1927
2084
|
const cves = obj.phases?.analyze?.matched_cves || [];
|
|
1928
2085
|
const baseline = obj.phases?.analyze?.catalog_baseline_cves || [];
|
|
1929
2086
|
if (cves.length) {
|
|
@@ -1987,6 +2144,57 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1987
2144
|
* Falls back to running every playbook with empty evidence (engine returns
|
|
1988
2145
|
* inconclusive findings + visibility gaps) when no --evidence is given.
|
|
1989
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
|
+
|
|
1990
2198
|
function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
1991
2199
|
const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
|
|
1992
2200
|
runOpts.session_id = sessionId;
|
|
@@ -2071,6 +2279,16 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2071
2279
|
results.push(result);
|
|
2072
2280
|
}
|
|
2073
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
|
+
|
|
2074
2292
|
emit({
|
|
2075
2293
|
ok: results.every(r => r.ok !== false),
|
|
2076
2294
|
session_id: sessionId,
|
|
@@ -2084,6 +2302,7 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
|
|
|
2084
2302
|
detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
|
|
2085
2303
|
inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
|
|
2086
2304
|
},
|
|
2305
|
+
jurisdiction_clock_rollup: jurisdictionClockRollup,
|
|
2087
2306
|
results,
|
|
2088
2307
|
}, pretty);
|
|
2089
2308
|
// v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
|
|
@@ -2099,6 +2318,13 @@ function cmdIngest(runner, args, runOpts, pretty) {
|
|
|
2099
2318
|
// `ingest` matches the AGENTS.md ingest contract. The submission JSON may
|
|
2100
2319
|
// carry playbook_id + directive_id; --domain/--directive flags override.
|
|
2101
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
|
+
}
|
|
2102
2328
|
if (args.evidence) {
|
|
2103
2329
|
try {
|
|
2104
2330
|
submission = readEvidence(args.evidence);
|
|
@@ -2459,7 +2685,64 @@ function walkAttestationDir(root, opts, candidates) {
|
|
|
2459
2685
|
}
|
|
2460
2686
|
}
|
|
2461
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
|
+
|
|
2462
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
|
+
}
|
|
2463
2746
|
// --latest [--playbook <id>] [--since <ISO>] — find prior attestation
|
|
2464
2747
|
// without requiring the operator to know the session-id.
|
|
2465
2748
|
let sessionId = args._[0];
|
|
@@ -2479,6 +2762,37 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2479
2762
|
if (!fs.existsSync(attFile)) {
|
|
2480
2763
|
return emitError(`reattest: no attestation found at ${attFile}`, { session_id: sessionId }, pretty);
|
|
2481
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
|
+
|
|
2482
2796
|
let prior;
|
|
2483
2797
|
try {
|
|
2484
2798
|
prior = JSON.parse(fs.readFileSync(attFile, "utf8"));
|
|
@@ -2544,6 +2858,10 @@ function cmdReattest(runner, args, runOpts, pretty) {
|
|
|
2544
2858
|
replayed_at: new Date().toISOString(),
|
|
2545
2859
|
replay_classification: replay.phases && replay.phases.detect && replay.phases.detect.classification,
|
|
2546
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"],
|
|
2547
2865
|
}, pretty);
|
|
2548
2866
|
}
|
|
2549
2867
|
|
|
@@ -3989,9 +4307,13 @@ function cmdAsk(runner, args, runOpts, pretty) {
|
|
|
3989
4307
|
* `run --all --ci` packaged as a verb so .github/workflows lines are short.
|
|
3990
4308
|
*
|
|
3991
4309
|
* Exit codes:
|
|
3992
|
-
* 0 PASS
|
|
3993
|
-
*
|
|
3994
|
-
*
|
|
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."
|
|
3995
4317
|
*/
|
|
3996
4318
|
function cmdCi(runner, args, runOpts, pretty) {
|
|
3997
4319
|
const scope = args.scope;
|
|
@@ -4063,6 +4385,11 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4063
4385
|
const results = [];
|
|
4064
4386
|
let fail = false;
|
|
4065
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 = [];
|
|
4066
4393
|
|
|
4067
4394
|
for (const id of ids) {
|
|
4068
4395
|
let pb;
|
|
@@ -4114,8 +4441,13 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4114
4441
|
failReasons.push(`${id}: rwep_delta=${rwepAdj - rwepBase} >= cap=${cap} (classification=inconclusive; operator evidence raised the score)`);
|
|
4115
4442
|
}
|
|
4116
4443
|
if (blockOnClock && clockStarted) {
|
|
4117
|
-
|
|
4118
|
-
|
|
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`);
|
|
4119
4451
|
}
|
|
4120
4452
|
}
|
|
4121
4453
|
|
|
@@ -4133,13 +4465,22 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4133
4465
|
const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
|
|
4134
4466
|
const totalForVerdict = results.length;
|
|
4135
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.")
|
|
4136
4475
|
const computedVerdict = blockedCount > 0
|
|
4137
4476
|
? "BLOCKED"
|
|
4138
|
-
:
|
|
4139
|
-
? "
|
|
4140
|
-
:
|
|
4141
|
-
? "
|
|
4142
|
-
:
|
|
4477
|
+
: clockStartedFail
|
|
4478
|
+
? "CLOCK_STARTED"
|
|
4479
|
+
: fail
|
|
4480
|
+
? "FAIL"
|
|
4481
|
+
: noEvidenceAllInconclusive
|
|
4482
|
+
? "NO_EVIDENCE"
|
|
4483
|
+
: "PASS";
|
|
4143
4484
|
|
|
4144
4485
|
// v0.12.9 (P2 #8 from production smoke): roll up per-playbook framework_gap
|
|
4145
4486
|
// mappings to the ci top-level. Phase 7 of the seven-phase contract surfaces
|
|
@@ -4180,8 +4521,15 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4180
4521
|
.filter(n => n && n.clock_started_at != null).length,
|
|
4181
4522
|
framework_gap_rollup: frameworkGapRollup,
|
|
4182
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),
|
|
4183
4530
|
verdict: computedVerdict,
|
|
4184
4531
|
fail_reasons: failReasons,
|
|
4532
|
+
clock_started_reasons: clockStartedReasons,
|
|
4185
4533
|
};
|
|
4186
4534
|
|
|
4187
4535
|
// v0.11.4 (#72): ci --format <fmt> previously emitted the full bundle
|
|
@@ -4241,6 +4589,15 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
4241
4589
|
process.exitCode = 4;
|
|
4242
4590
|
return;
|
|
4243
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
|
+
}
|
|
4244
4601
|
if (fail) {
|
|
4245
4602
|
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
4246
4603
|
// v0.11.11: exitCode + return so emit()'s stdout flushes.
|