@blamejs/exceptd-skills 0.12.0 → 0.12.2

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 CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.2 — 2026-05-13
4
+
5
+ **Patch: end-to-end scenario gate — staged-IoC harness in release workflow.**
6
+
7
+ 366 unit tests prove the engine works in isolation. They don't prove that, given a real repo containing a CVE-2026-45321 payload file in `node_modules/@tanstack/`, the CLI actually catches it. v0.12.2 adds that gate.
8
+
9
+ ### What ships
10
+
11
+ - `tests/e2e-scenarios/` — eight self-contained scenarios. Each is a directory holding a synthetic file tree (`fixtures/`), an evidence JSON, and an expectation JSON. The runner copies the fixture tree into a temp dir, runs the declared CLI verb against it, and diffs the result.
12
+
13
+ | # | Scenario | What it stages | Asserts |
14
+ |---|---|---|---|
15
+ | 01 | clean-repo | nothing | `classification: not_detected`, `compliance_theater: clear` |
16
+ | 02 | tanstack-worm-payload | `node_modules/@tanstack/react-router/router_init.js` | `detected` + jurisdiction clock starts |
17
+ | 03 | claude-session-start-hook | `.claude/settings.json` with `hooks.SessionStart` running `.vscode/setup.mjs` | `detected` |
18
+ | 04 | vscode-folder-open-task | `.vscode/tasks.json` with `runOptions.runOn: folderOpen` | `detected` |
19
+ | 05 | ci-cache-coresidency | `.github/workflows/` containing `pull_request_target` + `id-token: write` + shared `actions/cache` | `detected` |
20
+ | 06 | npmrc-no-cooldown | `package.json` with deps + no `.npmrc` cooldown | `inconclusive` (hardening recommendation) |
21
+ | 07 | cve-curation | invoke `refresh --curate` on a real human-curated entry | refusal with `human-curated` error |
22
+ | 08 | refresh-advisory | invoke `refresh --advisory` against an offline GHSA fixture | draft seed emitted, exit 3 |
23
+
24
+ - `scripts/run-e2e-scenarios.js` — iterates scenarios, supports `--filter=<regex>` + `--json`. Returns non-zero on any failure.
25
+ - `docker/test.Dockerfile` — new `e2e` target so the harness runs identically in CI containers and on a developer host (`npm run test:docker:e2e`).
26
+ - `npm run test:e2e` — local invocation (no Docker required).
27
+
28
+ ### Release-workflow integration
29
+
30
+ `.github/workflows/release.yml` now runs `npm run test:e2e` immediately after `npm run predeploy` and before `npm pack` / `npm publish`. A regression that breaks any playbook's detection layer — even one that passes every unit test — blocks the publish.
31
+
32
+ ### Coverage matrix
33
+
34
+ | Surface | Covered |
35
+ |---|---|
36
+ | `run sbom` with real IoC fixtures | scenarios 01-06 |
37
+ | `refresh --advisory` (offline fixture path) | scenario 08 |
38
+ | `refresh --curate` (human-curated refusal path) | scenario 07 |
39
+ | Exit-code semantics (0 / 2 / 3) | every scenario asserts `expect_exit` |
40
+ | `phases.detect.classification` + `phases.close.jurisdiction_notifications` | scenarios 02-05 |
41
+
42
+ Surface gaps to add in subsequent patches: `ai-run --stream` (JSONL contract), `attest verify` + `attest diff` against staged attestations, `doctor` with mock signature failures, `discover` against staged cwds.
43
+
44
+ ## 0.12.1 — 2026-05-13
45
+
46
+ **Patch: README + website docs for the v0.12.0 freshness surface.**
47
+
48
+ v0.12.0 shipped the GHSA source + `refresh --advisory` + `refresh --curate` but the README operator section + the website still showed the v0.11.x command set. v0.12.1 brings the docs into line:
49
+
50
+ - README: refresh command reference now lists `--network`, `--advisory <CVE-or-GHSA-ID>`, `--curate <CVE-ID>`, `--prefetch`, and the `ghsa` source. Operator section command examples updated. New `EXCEPTD_GHSA_FIXTURE` + `EXCEPTD_REGISTRY_FIXTURE` env vars documented.
51
+ - Website: "nightly upstream refresh" feature card extended to mention GHSA as the minutes-old disclosure path (vs days for KEV / NVD). Operator persona card command list updated to show the advisory + curate workflow.
52
+
53
+ No CLI / catalog / playbook changes — pure docs.
54
+
3
55
  ## 0.12.0 — 2026-05-13
4
56
 
5
57
  **Minor: catalog freshness from minutes-old disclosures, not days.**
package/README.md CHANGED
@@ -132,11 +132,11 @@ No clone, no signing keys, no Node 24 required for assistants that read directly
132
132
  You want to refresh CVE/RFC data, run currency checks, or generate reports. Install + invoke via `npx` (no global install needed):
133
133
 
134
134
  ```bash
135
- npx @blamejs/exceptd-skills prefetch # warm local cache of upstream data
136
- npx @blamejs/exceptd-skills refresh --from-cache --swarm
137
- npx @blamejs/exceptd-skills validate-cves --from-cache --no-fail
138
- npx @blamejs/exceptd-skills currency
139
- npx @blamejs/exceptd-skills report executive
135
+ npx @blamejs/exceptd-skills doctor # health check
136
+ npx @blamejs/exceptd-skills refresh --apply --swarm # pull KEV/NVD/EPSS/RFC/GHSA + apply
137
+ npx @blamejs/exceptd-skills refresh --advisory CVE-2026-45321 # seed one CVE draft from GHSA
138
+ npx @blamejs/exceptd-skills refresh --curate CVE-2026-45321 # surface editorial questions for a draft
139
+ npx @blamejs/exceptd-skills refresh --network # swap data/ from latest signed npm tarball
140
140
  ```
141
141
 
142
142
  For frequent use, install globally to skip the `npx` resolution every time:
@@ -146,14 +146,18 @@ npm install -g @blamejs/exceptd-skills
146
146
  exceptd help
147
147
  ```
148
148
 
149
- Air-gapped operation: run `exceptd prefetch` on a connected host, copy the resulting `.cache/upstream/` to the airgap, run `exceptd refresh --from-cache <path> --apply` over there. The vendored upstream snapshots replace every network call.
149
+ Air-gapped operation: run `exceptd refresh --prefetch` on a connected host, copy the resulting `.cache/upstream/` to the airgap, run `exceptd refresh --from-cache <path> --apply` over there. The vendored upstream snapshots replace every network call.
150
+
151
+ Fresh-disclosure workflow (v0.12.0): the nightly auto-PR job pulls KEV / NVD / EPSS / IETF / **GHSA** (added in v0.12.0). KEV typically takes days; NVD ~10 days; GHSA fires within hours of disclosure and covers npm + PyPI + Maven + Go + NuGet + …. New CVE IDs land as drafts (`_auto_imported: true`, `_draft: true`) that the catalog validator treats as warnings, not errors — operators get the fresh entry immediately, editorial review (framework gaps, IoCs, ATLAS/ATT&CK refs) follows via `exceptd refresh --curate <CVE-ID>`. For "I want this CVE today, not tomorrow": `exceptd refresh --advisory <CVE-or-GHSA-ID> --apply`.
150
152
 
151
153
  Optional env vars for higher rate budgets:
152
154
 
153
155
  | Variable | Purpose |
154
156
  |---|---|
155
157
  | `NVD_API_KEY` | Lifts NVD 2.0 from 5 → 50 requests per 30s window. Free key at <https://nvd.nist.gov/developers/request-an-api-key>. |
156
- | `GITHUB_TOKEN` | Lifts GitHub Releases (used for ATLAS / ATT&CK / D3FEND / CWE pin checks) from 60 → 5000 requests per hour. |
158
+ | `GITHUB_TOKEN` | Lifts GitHub Releases + GHSA from 60 → 5000 requests per hour. |
159
+ | `EXCEPTD_GHSA_FIXTURE` | Path to a JSON fixture matching the api.github.com/advisories shape. For offline tests + air-gap workflows. |
160
+ | `EXCEPTD_REGISTRY_FIXTURE` | Path to a JSON fixture matching the npm registry response. Used by `doctor --registry-check` + `run --upstream-check` + `refresh --network` for offline testing. |
157
161
 
158
162
  ### 3. Maintainer (extend / sign / publish)
159
163
 
@@ -275,9 +279,24 @@ exceptd refresh Refresh upstream catalogs + indexes.
275
279
  Replaces prefetch + refresh + build-indexes.
276
280
  --apply Write diffs back + rebuild indexes.
277
281
  --from-cache [<dir>] Read from prefetch cache.
278
- --no-network Dry-run.
282
+ --prefetch Populate the offline cache (alias for
283
+ --no-network).
284
+ --network (v0.11.14) Fetch latest signed catalog
285
+ snapshot from npm tarball, verify against
286
+ local public.pem, swap data/ in place.
287
+ --advisory <CVE-or-GHSA-ID> (v0.12.0) Seed a single catalog entry from
288
+ GitHub Advisory Database. Writes a draft
289
+ flagged _auto_imported. --apply commits it.
290
+ --curate <CVE-ID> (v0.12.0) Emit editorial questions + ranked
291
+ candidates (ATLAS/ATT&CK/CWE/framework) for
292
+ a draft catalog entry.
279
293
  --indexes-only Rebuild data/_indexes/*.json only.
280
294
 
295
+ Sources (default = all): kev | epss | nvd | rfc | pins | ghsa (v0.12.0).
296
+ GHSA covers npm, PyPI, Maven, Go, NuGet, etc. New CVE IDs land as drafts
297
+ that the catalog validator treats as warnings, not errors — editorial
298
+ review (framework gaps, IoCs, ATLAS/ATT&CK refs) is still required.
299
+
281
300
  exceptd skill <name> Show context for one skill.
282
301
  exceptd framework-gap <FW> <ref> One framework + one CVE/scenario, JSON
283
302
  or human. (Operates outside the seven-
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-13T02:34:42.448Z",
3
+ "generated_at": "2026-05-13T02:49:16.936Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "31cfbef4aa2a73ae93de1837209c958f5318fadbfc4481f06459617048fec44c",
7
+ "manifest.json": "c607b3254ea45ed898b325a4bacbfc1076d2669e813e2d4dcbbd9d6ab0cf73ec",
8
8
  "data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
9
9
  "data/cve-catalog.json": "e9a3a4ce988caa051e50a467f1cd9c0dcbf9e8f6f3e9522610baf196217b7bdc",
10
10
  "data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
package/keys/public.pem CHANGED
@@ -1,3 +1,3 @@
1
1
  -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEA8r4wi/onMuK7+R3naEBpBUU8LDfUlt0D67FKS7IpRp4=
2
+ MCowBQYDK2VwAyEALm7uQSdGjE9NSorvxoDnbqolbQaRXyGrgb82J5gUHhA=
3
3
  -----END PUBLIC KEY-----
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-13T02:31:32.493Z",
3
+ "_generated_at": "2026-05-13T02:48:32.579Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [
package/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exceptd-security",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation",
5
5
  "homepage": "https://exceptd.com",
6
6
  "license": "Apache-2.0",
@@ -52,7 +52,7 @@
52
52
  ],
53
53
  "last_threat_review": "2026-05-01",
54
54
  "signature": "Xk593pj7my6wPJbQBE47khpIUrPsp6N1lW7cE2T/VPPF5T+8C1yGKc9B8VphD7Q08yWFcbwF6HoWpA/+4uG9DA==",
55
- "signed_at": "2026-05-13T02:31:32.071Z",
55
+ "signed_at": "2026-05-13T02:48:32.136Z",
56
56
  "cwe_refs": [
57
57
  "CWE-125",
58
58
  "CWE-362",
@@ -116,7 +116,7 @@
116
116
  ],
117
117
  "last_threat_review": "2026-05-01",
118
118
  "signature": "nOgUu+LK9fy6ASTCoRGtx3ttgjZCl7WIkKu2wu06JEKVSpL2cKU3ex2tmVAvv11LBmpTH+b/0zvqXlzcxzHnCw==",
119
- "signed_at": "2026-05-13T02:31:32.073Z",
119
+ "signed_at": "2026-05-13T02:48:32.138Z",
120
120
  "cwe_refs": [
121
121
  "CWE-1039",
122
122
  "CWE-1426",
@@ -179,7 +179,7 @@
179
179
  ],
180
180
  "last_threat_review": "2026-05-01",
181
181
  "signature": "7FH1J9PlOyvcRCzRmggmenX9fIR0pi/veXihb3TeStcq1Rpuz1KHdOcJLqA9su4t2goYukKKCXHV6hx8hzplAA==",
182
- "signed_at": "2026-05-13T02:31:32.073Z",
182
+ "signed_at": "2026-05-13T02:48:32.138Z",
183
183
  "cwe_refs": [
184
184
  "CWE-22",
185
185
  "CWE-345",
@@ -225,7 +225,7 @@
225
225
  "framework_gaps": [],
226
226
  "last_threat_review": "2026-05-01",
227
227
  "signature": "FqTRjHfEgw56pyHnyWzNtnhzDMEePBtmuamtW/iyX+h4yqbvP4Fyr7NRjRs3EgqT4j7oHuEZhV9Jt6ZTBgN4AA==",
228
- "signed_at": "2026-05-13T02:31:32.073Z"
228
+ "signed_at": "2026-05-13T02:48:32.138Z"
229
229
  },
230
230
  {
231
231
  "name": "compliance-theater",
@@ -256,7 +256,7 @@
256
256
  ],
257
257
  "last_threat_review": "2026-05-01",
258
258
  "signature": "3fN4yotiIIq76PVTHwozCu28TzDZvWule6vX8SXUT3XXbIBSuvAO0M/euvc3pw3TdZ2UNf78dI18lOCNdJ0aAg==",
259
- "signed_at": "2026-05-13T02:31:32.074Z"
259
+ "signed_at": "2026-05-13T02:48:32.139Z"
260
260
  },
261
261
  {
262
262
  "name": "exploit-scoring",
@@ -285,7 +285,7 @@
285
285
  ],
286
286
  "last_threat_review": "2026-05-01",
287
287
  "signature": "yZfpk4lQMRXegj2ADWjMmZTchUN6Lxpv587O/0JMzbNkXQtD6FrSAQOBWjx8S7uQ/sTntxgGN7aQQDLxL9RWAA==",
288
- "signed_at": "2026-05-13T02:31:32.074Z"
288
+ "signed_at": "2026-05-13T02:48:32.139Z"
289
289
  },
290
290
  {
291
291
  "name": "rag-pipeline-security",
@@ -322,7 +322,7 @@
322
322
  ],
323
323
  "last_threat_review": "2026-05-01",
324
324
  "signature": "ABHkoqee67KdUyDZ3bvF+/DNxjGhPR/ehT6pfOnmUIMmkcQFHpZ0OUVXKiFUANaLgKLP1vg0VEmHOoxpNA3vAA==",
325
- "signed_at": "2026-05-13T02:31:32.075Z",
325
+ "signed_at": "2026-05-13T02:48:32.140Z",
326
326
  "cwe_refs": [
327
327
  "CWE-1395",
328
328
  "CWE-1426"
@@ -379,7 +379,7 @@
379
379
  ],
380
380
  "last_threat_review": "2026-05-01",
381
381
  "signature": "+Nd/2tgBnW+mEGX84QvkgR2To2J7kA+lB63BsADDKeCXeebFv6Vo9H1P4vyUkKHfe4fP0ndpy3agIZcUO/e/Dg==",
382
- "signed_at": "2026-05-13T02:31:32.075Z",
382
+ "signed_at": "2026-05-13T02:48:32.140Z",
383
383
  "d3fend_refs": [
384
384
  "D3-CA",
385
385
  "D3-CSPP",
@@ -414,7 +414,7 @@
414
414
  "framework_gaps": [],
415
415
  "last_threat_review": "2026-05-01",
416
416
  "signature": "VMNGFvowXLbBjZp5nvWloKkqyqHKhnSzbVRU3gX9quOZJHH56w2M4id+oDsXIjR0CfRRb7eXl/so0Hq4xLBuBQ==",
417
- "signed_at": "2026-05-13T02:31:32.075Z",
417
+ "signed_at": "2026-05-13T02:48:32.140Z",
418
418
  "cwe_refs": [
419
419
  "CWE-1188"
420
420
  ]
@@ -442,7 +442,7 @@
442
442
  "framework_gaps": [],
443
443
  "last_threat_review": "2026-05-01",
444
444
  "signature": "5MaJs7gPCuFlK4oAttLulAPOA1noeV+xD/UqVWaVyRedXZgebBGKjnlE2t1qmTugvxlNIfeAnBZapk+Wz3VAAg==",
445
- "signed_at": "2026-05-13T02:31:32.075Z"
445
+ "signed_at": "2026-05-13T02:48:32.141Z"
446
446
  },
447
447
  {
448
448
  "name": "global-grc",
@@ -474,7 +474,7 @@
474
474
  "framework_gaps": [],
475
475
  "last_threat_review": "2026-05-01",
476
476
  "signature": "S/YXUpI/mcG2FpdUTgMsccWBtTaR5A4Ph4QFQw31S9w9Hn/z3sOFHLkb1B5YSwlg+mMOtSIxMdet1eLGSZkTDg==",
477
- "signed_at": "2026-05-13T02:31:32.076Z"
477
+ "signed_at": "2026-05-13T02:48:32.141Z"
478
478
  },
479
479
  {
480
480
  "name": "zeroday-gap-learn",
@@ -501,7 +501,7 @@
501
501
  "framework_gaps": [],
502
502
  "last_threat_review": "2026-05-01",
503
503
  "signature": "AKS+JsmhhBtytY2eIMuydjkZOYprWCmQ+RqxyxcVG9XcEI29ZSM/JbVIINQHozFl7OPPrOu1ouiTnk7LOJ86Bg==",
504
- "signed_at": "2026-05-13T02:31:32.076Z"
504
+ "signed_at": "2026-05-13T02:48:32.141Z"
505
505
  },
506
506
  {
507
507
  "name": "pqc-first",
@@ -553,7 +553,7 @@
553
553
  ],
554
554
  "last_threat_review": "2026-05-01",
555
555
  "signature": "oEkK5bLS/G5RIHnxlNFJYdzhTJbKZnkJv+W4iS9UJ/uszZHgZGoxygELPc4kn3FowV5eE988SQYG4WKlXtNzCg==",
556
- "signed_at": "2026-05-13T02:31:32.077Z",
556
+ "signed_at": "2026-05-13T02:48:32.142Z",
557
557
  "cwe_refs": [
558
558
  "CWE-327"
559
559
  ],
@@ -600,7 +600,7 @@
600
600
  ],
601
601
  "last_threat_review": "2026-05-01",
602
602
  "signature": "nPV6YTo1rsNH49qUnZpfoNLEQZXuLNyV05QMUOgXKHYeVDjotYpWhLgyVXlRhjV/fStiA2sWQ0MOnEJ4FBIfDg==",
603
- "signed_at": "2026-05-13T02:31:32.077Z"
603
+ "signed_at": "2026-05-13T02:48:32.142Z"
604
604
  },
605
605
  {
606
606
  "name": "security-maturity-tiers",
@@ -637,7 +637,7 @@
637
637
  ],
638
638
  "last_threat_review": "2026-05-01",
639
639
  "signature": "7rirSEONz6O9Yyf46eTyuwkGizCj9FRcNHe5p7Qz6nhJoZQRW5FwW7n9opL0WlbIw8FDBYn1f22zgNUV87L5AQ==",
640
- "signed_at": "2026-05-13T02:31:32.077Z",
640
+ "signed_at": "2026-05-13T02:48:32.143Z",
641
641
  "cwe_refs": [
642
642
  "CWE-1188"
643
643
  ]
@@ -672,7 +672,7 @@
672
672
  "framework_gaps": [],
673
673
  "last_threat_review": "2026-05-11",
674
674
  "signature": "+evehnd2wSBb8uMTlTr5/aTN4bfLjsKzZJk/+OMLMOJrjCt+OuMU7EQC6xMUGeSc4cPEGajghDvq3xVaacV2Dw==",
675
- "signed_at": "2026-05-13T02:31:32.078Z"
675
+ "signed_at": "2026-05-13T02:48:32.143Z"
676
676
  },
677
677
  {
678
678
  "name": "attack-surface-pentest",
@@ -743,7 +743,7 @@
743
743
  "PTES revision incorporating AI-surface enumeration"
744
744
  ],
745
745
  "signature": "KHOXxloAYf7xqXjm2BaL3HVAZOmb7rMiMh20H/oaIkjN0WD1CnKCrRGPJn867uSFhCh/timkXolaiqD1L/h8Dg==",
746
- "signed_at": "2026-05-13T02:31:32.078Z"
746
+ "signed_at": "2026-05-13T02:48:32.143Z"
747
747
  },
748
748
  {
749
749
  "name": "fuzz-testing-strategy",
@@ -803,7 +803,7 @@
803
803
  "OSS-Fuzz-Gen / AI-assisted harness generation becoming the default expectation for OSS maintainers"
804
804
  ],
805
805
  "signature": "+ELdD+1AY5DymBitH7wU65CS60NY1nDoLowJAFn7cE5Gr/5jy9BTkyxsm7PEXaSlXWMOkTf/HQ+uyzyxUVD/Bw==",
806
- "signed_at": "2026-05-13T02:31:32.078Z"
806
+ "signed_at": "2026-05-13T02:48:32.143Z"
807
807
  },
808
808
  {
809
809
  "name": "dlp-gap-analysis",
@@ -878,7 +878,7 @@
878
878
  "Quebec Law 25, India DPDPA, KSA PDPL enforcement actions naming AI-tool prompt data as in-scope personal information"
879
879
  ],
880
880
  "signature": "8tFAhXAS8zZN3SUOdn+ZIu7lQ48JMOyBQ8SaObR3L/fDyFmDhufqleY2VzI3yigqlT/D4Y8FYxZHKmzXiALjDw==",
881
- "signed_at": "2026-05-13T02:31:32.079Z"
881
+ "signed_at": "2026-05-13T02:48:32.144Z"
882
882
  },
883
883
  {
884
884
  "name": "supply-chain-integrity",
@@ -955,7 +955,7 @@
955
955
  "OpenSSF model-signing — emerging Sigstore-based signing standard for ML model weights; track for production adoption"
956
956
  ],
957
957
  "signature": "YhvlD+6gdFGg7P6QtpWeb0n54/Ujlxc7I6o/bXtpkfPiy/JY4OJo5xdreb+mbytHkasmUErL5LsDtTCAVq0QAA==",
958
- "signed_at": "2026-05-13T02:31:32.079Z"
958
+ "signed_at": "2026-05-13T02:48:32.144Z"
959
959
  },
960
960
  {
961
961
  "name": "defensive-countermeasure-mapping",
@@ -1012,7 +1012,7 @@
1012
1012
  ],
1013
1013
  "last_threat_review": "2026-05-11",
1014
1014
  "signature": "AMdLkDx/e3ESI4NAnJhhcaas+Ru8VjrSn6v6RBbmmzoLCGo/vFxGraa1p/qF9udhVG+DdkbwHfbfKK5Im19KDw==",
1015
- "signed_at": "2026-05-13T02:31:32.079Z"
1015
+ "signed_at": "2026-05-13T02:48:32.145Z"
1016
1016
  },
1017
1017
  {
1018
1018
  "name": "identity-assurance",
@@ -1079,7 +1079,7 @@
1079
1079
  "d3fend_refs": [],
1080
1080
  "last_threat_review": "2026-05-11",
1081
1081
  "signature": "pSMHKkyWoZvRIuVtN7Vue51sP5MIy9lSaQa2YSAMhxjptx81cUnPt3S11/Tb9Ea1/eluMNQ+5F25eF2njr4mBQ==",
1082
- "signed_at": "2026-05-13T02:31:32.079Z"
1082
+ "signed_at": "2026-05-13T02:48:32.145Z"
1083
1083
  },
1084
1084
  {
1085
1085
  "name": "ot-ics-security",
@@ -1135,7 +1135,7 @@
1135
1135
  "d3fend_refs": [],
1136
1136
  "last_threat_review": "2026-05-11",
1137
1137
  "signature": "qjky+ZTX1DP7uRRMQZq7S7P9/uaJEoB1dy4RZ1l37Q4OO3k2ryfL+7o0Cgm/piuafJfH+dqUeNCRrVefj4r8Dw==",
1138
- "signed_at": "2026-05-13T02:31:32.080Z"
1138
+ "signed_at": "2026-05-13T02:48:32.146Z"
1139
1139
  },
1140
1140
  {
1141
1141
  "name": "coordinated-vuln-disclosure",
@@ -1187,7 +1187,7 @@
1187
1187
  "NYDFS 23 NYCRR 500 amendments potentially adding explicit CVD program requirements"
1188
1188
  ],
1189
1189
  "signature": "F86Zl/I+dBzHYRUuGWsjDQI2F/I/vhzwZUFMqhNfKUzRbMf6mafOX2APCPYTp3eP1DvvvfL3Yc0hb1R5Q4nOAg==",
1190
- "signed_at": "2026-05-13T02:31:32.080Z"
1190
+ "signed_at": "2026-05-13T02:48:32.146Z"
1191
1191
  },
1192
1192
  {
1193
1193
  "name": "threat-modeling-methodology",
@@ -1237,7 +1237,7 @@
1237
1237
  "PASTA v2 updates incorporating AI/ML application threats"
1238
1238
  ],
1239
1239
  "signature": "D/4d5NcJScNH58ADXsSrVzTmLSWZpUZTdyhtDkJlC0twSMNczOiDsXgYFitBaZgGdv5nVd00viR45mNrsaZ4BQ==",
1240
- "signed_at": "2026-05-13T02:31:32.081Z"
1240
+ "signed_at": "2026-05-13T02:48:32.147Z"
1241
1241
  },
1242
1242
  {
1243
1243
  "name": "webapp-security",
@@ -1311,7 +1311,7 @@
1311
1311
  "d3fend_refs": [],
1312
1312
  "last_threat_review": "2026-05-11",
1313
1313
  "signature": "UOXaUtpcFjXyDQ70z2PaGu6K3pABtXp+7YzO6eGVGpN1CxXpPq/xW/CnTng6B7wk9WSsqD0OORBJp4VCjiVfAQ==",
1314
- "signed_at": "2026-05-13T02:31:32.081Z"
1314
+ "signed_at": "2026-05-13T02:48:32.147Z"
1315
1315
  },
1316
1316
  {
1317
1317
  "name": "ai-risk-management",
@@ -1361,7 +1361,7 @@
1361
1361
  "d3fend_refs": [],
1362
1362
  "last_threat_review": "2026-05-11",
1363
1363
  "signature": "IVKygsrFjiM64fQVbd2PT6jDjs6fm5nKwJSqGfK53gG0S9wdHC4QYuh+LWlI/2ftvIKjjedLQ6FRyTrqpDEuDw==",
1364
- "signed_at": "2026-05-13T02:31:32.081Z"
1364
+ "signed_at": "2026-05-13T02:48:32.147Z"
1365
1365
  },
1366
1366
  {
1367
1367
  "name": "sector-healthcare",
@@ -1421,7 +1421,7 @@
1421
1421
  "d3fend_refs": [],
1422
1422
  "last_threat_review": "2026-05-11",
1423
1423
  "signature": "P+CdSu8ZJCNUU4nTa09Voh2PcYF3y/AFJn4v7cjVIGo9FbbqO7MwvGN7cJ+aSRs2/3NMUXX4eupcODslxYyJDw==",
1424
- "signed_at": "2026-05-13T02:31:32.082Z"
1424
+ "signed_at": "2026-05-13T02:48:32.148Z"
1425
1425
  },
1426
1426
  {
1427
1427
  "name": "sector-financial",
@@ -1502,7 +1502,7 @@
1502
1502
  "TIBER-EU framework v2.0 alignment with DORA TLPT RTS (JC 2024/40); cross-recognition with CBEST and iCAST"
1503
1503
  ],
1504
1504
  "signature": "zpEfh181Sc0b0cvRf/31Ir1f8lD4V5tehTogO3TJMxdKmXu06IAK7hrhBcLA/jFBv3xDDwrWW3sHzChVhWDeDA==",
1505
- "signed_at": "2026-05-13T02:31:32.082Z"
1505
+ "signed_at": "2026-05-13T02:48:32.148Z"
1506
1506
  },
1507
1507
  {
1508
1508
  "name": "sector-federal-government",
@@ -1571,7 +1571,7 @@
1571
1571
  "Australia PSPF 2024 revision and ISM quarterly updates — track for Essential Eight Maturity Level requirements for federal entities"
1572
1572
  ],
1573
1573
  "signature": "7NpQlPu1DkpY9f+Frv/LLBHWUUe/qTM80c+xeYDxOzweXhvJGE/dnDCjglYHTjxT82L9cVxzBezvLEne20UpBg==",
1574
- "signed_at": "2026-05-13T02:31:32.082Z"
1574
+ "signed_at": "2026-05-13T02:48:32.148Z"
1575
1575
  },
1576
1576
  {
1577
1577
  "name": "sector-energy",
@@ -1636,7 +1636,7 @@
1636
1636
  "ICS-CERT advisory feed (https://www.cisa.gov/news-events/cybersecurity-advisories/ics-advisories) for vendor CVEs in Siemens, Rockwell, Schneider Electric, ABB, GE Vernova, Hitachi Energy, AVEVA / OSIsoft PI"
1637
1637
  ],
1638
1638
  "signature": "4rhyHN5HykK7MQUmhvaTeDGj6Qf5swDd5ry8foh4KBvTkRKxTI/XyxconFGm5FASnySGPLMxX6m4JZAq5wiNBg==",
1639
- "signed_at": "2026-05-13T02:31:32.083Z"
1639
+ "signed_at": "2026-05-13T02:48:32.149Z"
1640
1640
  },
1641
1641
  {
1642
1642
  "name": "api-security",
@@ -1705,7 +1705,7 @@
1705
1705
  "d3fend_refs": [],
1706
1706
  "last_threat_review": "2026-05-11",
1707
1707
  "signature": "hS1izPhETclITK7fp6R67dhy+wFDti/YsJ2M5I1gDjeWZYK41WuxeYSyt5xEHbCr3WCGDFJe77jkK1MWkxk2BA==",
1708
- "signed_at": "2026-05-13T02:31:32.083Z"
1708
+ "signed_at": "2026-05-13T02:48:32.149Z"
1709
1709
  },
1710
1710
  {
1711
1711
  "name": "cloud-security",
@@ -1786,7 +1786,7 @@
1786
1786
  "CISA KEV additions for cloud-control-plane CVEs (IMDSv1 abuses, federation token mishandling, cross-tenant boundary failures); CISA Cybersecurity Advisories for cross-cloud advisories"
1787
1787
  ],
1788
1788
  "signature": "kuatqNZoRnv+oeyrxbnk+m37JRBIgRAWnDp0/IYLnoBOybiG09RzLILJraxjhvdSNCgo7WXTeBO3Y6a3Ji9MAA==",
1789
- "signed_at": "2026-05-13T02:31:32.083Z"
1789
+ "signed_at": "2026-05-13T02:48:32.149Z"
1790
1790
  },
1791
1791
  {
1792
1792
  "name": "container-runtime-security",
@@ -1848,7 +1848,7 @@
1848
1848
  "d3fend_refs": [],
1849
1849
  "last_threat_review": "2026-05-11",
1850
1850
  "signature": "Btb3/7fjPFopFVdxP7+E6n322gnAAwd7OPrnuqatq6c1rXTD9aXKxiBeCmWxs8zYbIbE/lFoe9R2g6uTp8ZDBg==",
1851
- "signed_at": "2026-05-13T02:31:32.084Z"
1851
+ "signed_at": "2026-05-13T02:48:32.150Z"
1852
1852
  },
1853
1853
  {
1854
1854
  "name": "mlops-security",
@@ -1919,7 +1919,7 @@
1919
1919
  "MITRE ATLAS v5.2 — track AML.T0010 sub-technique expansion and any new MLOps-pipeline-specific TTPs"
1920
1920
  ],
1921
1921
  "signature": "TBWnlgdllW7K1F10HCJ7p4dbLeS3lyNWm+7mNNtyZu7jB1V5AauG1P7sb1nLLqwKqeGlHS1F0eh/BNiuAvkABg==",
1922
- "signed_at": "2026-05-13T02:31:32.084Z"
1922
+ "signed_at": "2026-05-13T02:48:32.151Z"
1923
1923
  },
1924
1924
  {
1925
1925
  "name": "incident-response-playbook",
@@ -1981,7 +1981,7 @@
1981
1981
  "NYDFS 23 NYCRR 500.17 amendments tightening ransom-payment 24h disclosure operationalization"
1982
1982
  ],
1983
1983
  "signature": "FVAXpD6sIoOLQSPtZSLLsXQnc2o2hRwiFj4xK8zEWJVkUWGqvAWRrngie7O2DRKIbWqjO5h9EevVYSzhwYHCAA==",
1984
- "signed_at": "2026-05-13T02:31:32.085Z"
1984
+ "signed_at": "2026-05-13T02:48:32.151Z"
1985
1985
  },
1986
1986
  {
1987
1987
  "name": "email-security-anti-phishing",
@@ -2034,7 +2034,7 @@
2034
2034
  "d3fend_refs": [],
2035
2035
  "last_threat_review": "2026-05-11",
2036
2036
  "signature": "0HDt3Qklee4FQeKoZfwr+8qdq2pVDS0a+c7JxVw1hV/bl8+YTPaPjPTAhQUnbhUCa5cGo7G4MBQ1AifQTMJdDA==",
2037
- "signed_at": "2026-05-13T02:31:32.085Z"
2037
+ "signed_at": "2026-05-13T02:48:32.152Z"
2038
2038
  },
2039
2039
  {
2040
2040
  "name": "age-gates-child-safety",
@@ -2102,7 +2102,7 @@
2102
2102
  "US state adult-site age-verification laws — 19+ states by mid-2026 (TX HB 18 upheld by SCOTUS June 2025 in Free Speech Coalition v. Paxton); track ongoing challenges in remaining states"
2103
2103
  ],
2104
2104
  "signature": "UyPSKUztZI/daHCRTnAh6ryoKLX4xyjuG+EaNMPRVuCz2gANGl1F/NozDsw7R2koMUwSFoiYTzwqDvo1tpuKAg==",
2105
- "signed_at": "2026-05-13T02:31:32.085Z"
2105
+ "signed_at": "2026-05-13T02:48:32.152Z"
2106
2106
  }
2107
2107
  ]
2108
2108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",
@@ -87,6 +87,8 @@
87
87
  "prepublishOnly": "node -e \"if(process.env.EXCEPTD_SKIP_PREPUBLISH_PREDEPLOY!=='1'){const r=require('child_process').spawnSync(process.execPath,['scripts/predeploy.js'],{stdio:'inherit'});if(r.status){process.exit(r.status)}}\" && node lib/validate-package.js",
88
88
  "test:docker": "docker build --target predeploy -t exceptd-test:predeploy -f docker/test.Dockerfile . && docker run --rm exceptd-test:predeploy",
89
89
  "test:docker:fresh": "docker build --target fresh-bootstrap -t exceptd-test:fresh-bootstrap -f docker/test.Dockerfile . && docker run --rm exceptd-test:fresh-bootstrap",
90
+ "test:e2e": "node scripts/run-e2e-scenarios.js",
91
+ "test:docker:e2e": "docker build --target e2e -t exceptd-test:e2e -f docker/test.Dockerfile . && docker run --rm exceptd-test:e2e",
90
92
  "scan": "node orchestrator/index.js scan",
91
93
  "dispatch": "node orchestrator/index.js dispatch",
92
94
  "currency": "node orchestrator/index.js currency",
package/sbom.cdx.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:18c50e92-4147-4656-90ae-5f74bba71443",
4
+ "serialNumber": "urn:uuid:f4ef44ca-296e-4961-bf84-1231c24d3c05",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-13T02:31:32.904Z",
7
+ "timestamp": "2026-05-13T02:48:32.992Z",
8
8
  "tools": [
9
9
  {
10
10
  "name": "hand-written",
@@ -13,10 +13,10 @@
13
13
  }
14
14
  ],
15
15
  "component": {
16
- "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.0",
16
+ "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.12.2",
17
17
  "type": "application",
18
18
  "name": "@blamejs/exceptd-skills",
19
- "version": "0.12.0",
19
+ "version": "0.12.2",
20
20
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
21
21
  "licenses": [
22
22
  {
@@ -25,11 +25,11 @@
25
25
  }
26
26
  }
27
27
  ],
28
- "purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.0",
28
+ "purl": "pkg:npm/%40blamejs/exceptd-skills@0.12.2",
29
29
  "externalReferences": [
30
30
  {
31
31
  "type": "distribution",
32
- "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.0"
32
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.12.2"
33
33
  },
34
34
  {
35
35
  "type": "vcs",
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * scripts/run-e2e-scenarios.js
6
+ *
7
+ * Drives the end-to-end scenario harness under tests/e2e-scenarios/. Each
8
+ * scenario directory stages a synthetic file tree (real IoC patterns the
9
+ * playbooks check for) + an evidence.json + an expect.json. The runner:
10
+ *
11
+ * 1. mkdtemp a working dir
12
+ * 2. recursive-copy fixtures/ into it
13
+ * 3. recursive-copy any evidence.json next to scenario.json into it
14
+ * 4. cd into the working dir
15
+ * 5. spawnSync the CLI with the scenario's verb + args
16
+ * 6. parse stdout as JSON
17
+ * 7. diff against expect.json (path-based assertions)
18
+ *
19
+ * Container parity: this script is invoked unchanged inside the Docker
20
+ * `e2e` target (npm run test:docker:e2e). The container only adds Linux
21
+ * file-permission realism and Node version pinning; the script itself
22
+ * runs identically on host + container.
23
+ *
24
+ * Release gate: .github/workflows/release.yml runs this BEFORE
25
+ * `npm publish` so a regression that breaks any playbook detection
26
+ * blocks the release.
27
+ *
28
+ * Zero npm deps. Node 24 stdlib only.
29
+ */
30
+
31
+ const fs = require("fs");
32
+ const path = require("path");
33
+ const os = require("os");
34
+ const { spawnSync } = require("child_process");
35
+
36
+ const ROOT = path.resolve(__dirname, "..");
37
+ const CLI = path.join(ROOT, "bin", "exceptd.js");
38
+ const SCENARIO_DIR = path.join(ROOT, "tests", "e2e-scenarios");
39
+ const FIXTURE_DIR = path.join(ROOT, "tests", "fixtures");
40
+
41
+ function copyRecursive(src, dst) {
42
+ const stat = fs.statSync(src);
43
+ if (stat.isDirectory()) {
44
+ fs.mkdirSync(dst, { recursive: true });
45
+ for (const entry of fs.readdirSync(src)) {
46
+ copyRecursive(path.join(src, entry), path.join(dst, entry));
47
+ }
48
+ } else {
49
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
50
+ fs.copyFileSync(src, dst);
51
+ }
52
+ }
53
+
54
+ function getJsonPath(obj, dotted) {
55
+ return dotted.split(".").reduce((acc, key) => acc?.[key], obj);
56
+ }
57
+
58
+ function diffExpect(jsonBody, expect, ctx) {
59
+ const failures = [];
60
+ if (expect.json_path_equals) {
61
+ for (const [p, want] of Object.entries(expect.json_path_equals)) {
62
+ const got = getJsonPath(jsonBody, p);
63
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
64
+ failures.push(`json_path_equals.${p}: want ${JSON.stringify(want)}, got ${JSON.stringify(got)}`);
65
+ }
66
+ }
67
+ }
68
+ if (expect.json_path_present) {
69
+ for (const p of expect.json_path_present) {
70
+ const got = getJsonPath(jsonBody, p);
71
+ if (got === undefined || got === null) {
72
+ failures.push(`json_path_present.${p}: missing`);
73
+ }
74
+ }
75
+ }
76
+ if (expect.json_path_min) {
77
+ for (const [p, min] of Object.entries(expect.json_path_min)) {
78
+ const got = getJsonPath(jsonBody, p);
79
+ if (typeof got !== "number" || got < min) {
80
+ failures.push(`json_path_min.${p}: want >= ${min}, got ${JSON.stringify(got)}`);
81
+ }
82
+ }
83
+ }
84
+ if (expect.json_path_match) {
85
+ for (const [p, regex] of Object.entries(expect.json_path_match)) {
86
+ const got = getJsonPath(jsonBody, p);
87
+ if (typeof got !== "string" || !new RegExp(regex).test(got)) {
88
+ failures.push(`json_path_match.${p}: want match /${regex}/, got ${JSON.stringify(got)}`);
89
+ }
90
+ }
91
+ }
92
+ if (expect.stderr_must_not_match) {
93
+ for (const regex of expect.stderr_must_not_match) {
94
+ if (new RegExp(regex).test(ctx.stderr)) {
95
+ failures.push(`stderr_must_not_match /${regex}/: stderr contains it`);
96
+ }
97
+ }
98
+ }
99
+ return failures;
100
+ }
101
+
102
+ function tryParseJson(s) {
103
+ if (!s) return null;
104
+ try { return JSON.parse(s.trim()); } catch { /* ignore */ }
105
+ // Some verbs may emit trailing logs; pick the LAST complete JSON object on stdout.
106
+ const lines = s.trim().split("\n");
107
+ for (let i = lines.length - 1; i >= 0; i--) {
108
+ try { return JSON.parse(lines[i]); } catch { /* keep looking */ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ function runScenario(scenarioPath) {
114
+ const name = path.basename(scenarioPath);
115
+ const scenarioFile = path.join(scenarioPath, "scenario.json");
116
+ if (!fs.existsSync(scenarioFile)) {
117
+ return { name, skipped: true, reason: "no scenario.json" };
118
+ }
119
+ const scenario = JSON.parse(fs.readFileSync(scenarioFile, "utf8"));
120
+ const expect = fs.existsSync(path.join(scenarioPath, "expect.json"))
121
+ ? JSON.parse(fs.readFileSync(path.join(scenarioPath, "expect.json"), "utf8"))
122
+ : {};
123
+
124
+ // Stage temp working dir
125
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), `e2e-${name}-`));
126
+ try {
127
+ const fixturesDir = path.join(scenarioPath, "fixtures");
128
+ if (fs.existsSync(fixturesDir)) copyRecursive(fixturesDir, work);
129
+ const evidenceSrc = path.join(scenarioPath, "evidence.json");
130
+ if (fs.existsSync(evidenceSrc)) {
131
+ fs.copyFileSync(evidenceSrc, path.join(work, "evidence.json"));
132
+ }
133
+
134
+ // Resolve env. @@FIXTURE@@ in env values expands to ROOT/tests/fixtures.
135
+ const env = { ...process.env, EXCEPTD_DEPRECATION_SHOWN: "1", EXCEPTD_UNSIGNED_WARNED: "1" };
136
+ if (scenario.env) {
137
+ for (const [k, v] of Object.entries(scenario.env)) {
138
+ env[k] = String(v).replace(/@@FIXTURE@@/g, FIXTURE_DIR);
139
+ }
140
+ }
141
+
142
+ // Resolve args
143
+ const args = (scenario.args || []).slice();
144
+
145
+ // Verb routing. `refresh` + `refresh-curate` are not the same as `run` —
146
+ // the dispatcher in bin/exceptd.js handles the translation, so we just
147
+ // pass the verb + args verbatim. `refresh-curate` is the internal name
148
+ // for `refresh --curate`; surfaced here for test directness.
149
+ const verb = scenario.verb;
150
+ let cmd, cmdArgs;
151
+ if (verb === "refresh-curate") {
152
+ // Invoke the curation helper directly. Production path is via the
153
+ // dispatcher in bin/exceptd.js (which dispatches refresh --curate).
154
+ cmd = process.execPath;
155
+ cmdArgs = [path.join(ROOT, "lib", "cve-curation.js"), ...args];
156
+ } else {
157
+ cmd = process.execPath;
158
+ cmdArgs = [CLI, verb, ...args];
159
+ }
160
+
161
+ const res = spawnSync(cmd, cmdArgs, {
162
+ cwd: work,
163
+ encoding: "utf8",
164
+ env,
165
+ timeout: 60000,
166
+ });
167
+
168
+ const stdout = res.stdout || "";
169
+ const stderr = res.stderr || "";
170
+ const status = res.status;
171
+ const body = tryParseJson(stdout);
172
+
173
+ const failures = [];
174
+ if (typeof scenario.expect_exit === "number" && status !== scenario.expect_exit) {
175
+ failures.push(`exit: want ${scenario.expect_exit}, got ${status}`);
176
+ }
177
+ if (!body && (expect.json_path_equals || expect.json_path_present || expect.json_path_min || expect.json_path_match)) {
178
+ failures.push(`stdout did not parse as JSON; first 200 chars: ${stdout.slice(0, 200)}`);
179
+ }
180
+ if (body) failures.push(...diffExpect(body, expect, { stdout, stderr, status }));
181
+
182
+ return {
183
+ name,
184
+ description: scenario.description || "",
185
+ ok: failures.length === 0,
186
+ exit_status: status,
187
+ failures,
188
+ stdout_preview: stdout.slice(0, 200),
189
+ stderr_preview: stderr.slice(0, 200),
190
+ };
191
+ } finally {
192
+ fs.rmSync(work, { recursive: true, force: true });
193
+ }
194
+ }
195
+
196
+ function main() {
197
+ const filter = process.argv.find(a => a.startsWith("--filter="));
198
+ const filterRe = filter ? new RegExp(filter.slice("--filter=".length)) : null;
199
+ const json = process.argv.includes("--json");
200
+
201
+ const scenarios = fs.readdirSync(SCENARIO_DIR)
202
+ .filter(d => /^\d+-/.test(d))
203
+ .filter(d => !filterRe || filterRe.test(d))
204
+ .map(d => path.join(SCENARIO_DIR, d))
205
+ .sort();
206
+
207
+ const results = [];
208
+ for (const s of scenarios) {
209
+ results.push(runScenario(s));
210
+ }
211
+
212
+ const failed = results.filter(r => !r.ok && !r.skipped);
213
+ const passed = results.filter(r => r.ok);
214
+ const skipped = results.filter(r => r.skipped);
215
+
216
+ if (json) {
217
+ process.stdout.write(JSON.stringify({
218
+ verb: "e2e",
219
+ total: results.length,
220
+ passed: passed.length,
221
+ failed: failed.length,
222
+ skipped: skipped.length,
223
+ results,
224
+ }, null, 2) + "\n");
225
+ } else {
226
+ for (const r of results) {
227
+ const tag = r.skipped ? "SKIP" : r.ok ? "PASS" : "FAIL";
228
+ process.stdout.write(`${tag} ${r.name}\n`);
229
+ if (!r.ok && !r.skipped) {
230
+ for (const f of r.failures) process.stdout.write(` - ${f}\n`);
231
+ if (r.stderr_preview) process.stdout.write(` stderr: ${r.stderr_preview}\n`);
232
+ }
233
+ }
234
+ process.stdout.write(`\n${passed.length}/${results.length} scenarios passed${failed.length ? `, ${failed.length} failed` : ""}${skipped.length ? `, ${skipped.length} skipped` : ""}.\n`);
235
+ }
236
+
237
+ process.exit(failed.length === 0 ? 0 : 1);
238
+ }
239
+
240
+ main();