@blamejs/exceptd-skills 0.11.13 → 0.11.14
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 +39 -0
- package/bin/exceptd.js +180 -21
- package/data/_indexes/_meta.json +2 -2
- package/keys/public.pem +1 -1
- package/lib/refresh-external.js +47 -12
- package/lib/refresh-network.js +376 -0
- package/lib/upstream-check-cli.js +66 -0
- package/lib/upstream-check.js +145 -0
- package/manifest-snapshot.json +1 -1
- package/manifest.json +39 -39
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.14 — 2026-05-13
|
|
4
|
+
|
|
5
|
+
**Patch: items 129-134 + freshness surface — claims-vs-reality gap closure + opt-in registry-check.**
|
|
6
|
+
|
|
7
|
+
### New: freshness surface (all opt-in, all offline-safe)
|
|
8
|
+
|
|
9
|
+
- **`doctor --registry-check`.** Queries the npm registry for the latest published version + publish date. Reports `local_version`, `latest_version`, `days_since_latest_publish`, and a `behind` / `same` / `ahead` flag. Routed through a child process so the call is bounded by a hard timeout; offline degrades to a structured warning, not a hang. Opt-in: doctor without the flag stays offline.
|
|
10
|
+
|
|
11
|
+
- **`run --upstream-check`.** Same registry call, fires before phase-4 detect. Surfaces an `upstream_check` block on the run result + a visible stderr warning when the local catalog is behind. Operators wiring CI gates can read `result.upstream_check.behind` to decide whether to trust today's findings. Doesn't fetch the catalog — only compares timestamps.
|
|
12
|
+
|
|
13
|
+
- **`refresh --network`.** Fetches the latest signed catalog snapshot from the maintainer's npm-published tarball, verifies every skill's Ed25519 signature against the `keys/public.pem` already in the operator's install, and swaps `data/` + `skills/` + `manifest.json` in place. Same trust anchor as `npm update -g`; only the data slice changes, so CLI/lib code stays pinned. Refuses the swap on public-key fingerprint mismatch (key rotation requires explicit `npm update -g` so the trust transition is auditable). Refuses when the install dir isn't writable (typical global installs) and points operators at `npm update -g` instead. Includes `--dry-run` for verifying signatures without applying. Backs up the prior `data/` to a timestamped dir so rollback is one `mv` away.
|
|
14
|
+
|
|
15
|
+
All three honor `EXCEPTD_REGISTRY_FIXTURE` env var (path to a JSON file mimicking the registry response) so test runners and air-gapped operators can exercise the freshness paths offline.
|
|
16
|
+
|
|
17
|
+
### Bugs
|
|
18
|
+
|
|
19
|
+
- **#129 air-gap workflow is now operator-accessible.** Pre-0.11.14 the docs implied `refresh --from-cache` worked offline but the cache-population path wasn't surfaced; an empty cache produced a stack trace. Now `refresh --prefetch` is the operator-facing alias for the prefetch script (legacy `--no-network` retained). Missing-cache errors emit a structured hint that names the exact command: "(1) on connected host: `exceptd refresh --prefetch`, (2) copy `.cache/upstream/`, (3) offline: `exceptd refresh --from-cache --apply`." Help text rewritten to document the workflow.
|
|
20
|
+
|
|
21
|
+
- **#130 `exceptd path copy` writes to the clipboard.** Previously the `copy` argument was silently consumed and the path was just printed — operators wondering "did anything happen?" had no signal. Now the verb invokes the platform clipboard tool (`clip` on Windows, `pbcopy` on macOS, `wl-copy` / `xclip` / `xsel` on Linux), confirms the copy on stderr, and still prints the path on stdout so shell consumers like `cd "$(exceptd path)"` continue to work. When no clipboard tool is available, a clear warning fires instead of a silent fallthrough.
|
|
22
|
+
|
|
23
|
+
- **#131 `run <skill-name>` suggests the right playbook.** 13 playbooks vs 38 skills with a many-to-many relationship: operators routinely typed `run kernel-lpe-triage` (a skill) and got "Playbook not found." Now the error names the playbook(s) that load the skill (e.g. `kernel`), distinguishes skill-vs-playbook semantics, and suggests both `exceptd run <playbook>` (execute) and `exceptd skill <name>` (read). Near-matches on unknown ids also surface (`run secret` → "Did you mean: secrets?"). Landing site updated to clarify the distinction near the skills grid.
|
|
24
|
+
|
|
25
|
+
- **#134 `ci` exit-code matrix puts BLOCKED before FAIL.** Pre-0.11.14 a preflight halt produced exit 2 (FAIL) — indistinguishable from "playbook detected a real problem." Operators wiring CI gates against `exit 2` couldn't separate "we never executed" from "we executed and found something." Now the precedence is BLOCKED (4) → FAIL (2) → NO-DATA (3) → PASS (0). The earlier `if (fail)` short-circuit was rearranged so blocked counts take precedence.
|
|
26
|
+
|
|
27
|
+
### Website (operator-facing)
|
|
28
|
+
|
|
29
|
+
- **#132** `exceptd build-indexes` references replaced with `exceptd refresh --indexes-only`.
|
|
30
|
+
- **#133** "13-gate predeploy" feature card relabeled "13-gate release hygiene" and explicitly disambiguated from the operator-facing `exceptd ci` verb.
|
|
31
|
+
- **#131** Skills grid header clarifies "skills are read-only; playbooks execute" with the three relevant verbs.
|
|
32
|
+
- **#129** Operator persona card shows the actual air-gap workflow: `refresh --prefetch` → copy → `refresh --from-cache --apply`.
|
|
33
|
+
|
|
34
|
+
### Tests
|
|
35
|
+
|
|
36
|
+
7 new regression cases. 354 total. Notable: `#125/#134` now triggers a REAL preflight halt by submitting `repo-context: false` keyed by playbook id (autoDetectPreconditions can't override an explicit submission), and asserts `r.status === 4` not just non-zero — the earlier test only caught "not 0" which my v0.11.12 "fix" passed by coincidence (no-evidence → exit 3, also non-zero).
|
|
37
|
+
|
|
38
|
+
### Lesson codified
|
|
39
|
+
|
|
40
|
+
When a "fix" passes a regression test by coincidence (any non-zero exit satisfies "not 0"), the test is too weak. Tests must assert the EXACT contract — exit 4, not "any non-zero." Added to CLAUDE.md.
|
|
41
|
+
|
|
3
42
|
## 0.11.13 — 2026-05-13
|
|
4
43
|
|
|
5
44
|
**Patch: the final two stragglers — universal `ok:false` exit and empty-submission diff counters.**
|
package/bin/exceptd.js
CHANGED
|
@@ -71,6 +71,7 @@ const COMMANDS = {
|
|
|
71
71
|
"-h": null,
|
|
72
72
|
prefetch: () => path.join(PKG_ROOT, "lib", "prefetch.js"),
|
|
73
73
|
refresh: () => path.join(PKG_ROOT, "lib", "refresh-external.js"),
|
|
74
|
+
"refresh-network": () => path.join(PKG_ROOT, "lib", "refresh-network.js"),
|
|
74
75
|
"build-indexes": () => path.join(PKG_ROOT, "scripts", "build-indexes.js"),
|
|
75
76
|
verify: () => path.join(PKG_ROOT, "lib", "verify.js"),
|
|
76
77
|
scan: () => path.join(PKG_ROOT, "orchestrator", "index.js"),
|
|
@@ -196,6 +197,9 @@ v0.11.0 canonical surface
|
|
|
196
197
|
--ci exit-code gate (use \`exceptd ci\` instead)
|
|
197
198
|
--operator <name> bind attestation to identity
|
|
198
199
|
--ack explicit jurisdiction-consent
|
|
200
|
+
--upstream-check (v0.11.14) opt-in registry freshness
|
|
201
|
+
check before detect; warns if local
|
|
202
|
+
catalog is behind latest published
|
|
199
203
|
--session-id <id> reuse session id (collision refused)
|
|
200
204
|
--force-overwrite override session collision refusal
|
|
201
205
|
--session-key <hex> HMAC sign evidence_package
|
|
@@ -218,6 +222,8 @@ v0.11.0 canonical surface
|
|
|
218
222
|
doctor Health check: signatures + currency + cve catalog
|
|
219
223
|
+ rfc catalog + attestation-signing status.
|
|
220
224
|
--signatures | --currency | --cves | --rfcs
|
|
225
|
+
--registry-check (v0.11.14) opt-in: query npm registry
|
|
226
|
+
for latest published version + days behind
|
|
221
227
|
|
|
222
228
|
ci One-shot CI gate. Exit codes: 0 PASS, 2 detected/escalate,
|
|
223
229
|
3 ran-but-no-evidence, 4 blocked (ok:false), 1 framework error.
|
|
@@ -242,6 +248,13 @@ v0.11.0 canonical surface
|
|
|
242
248
|
|
|
243
249
|
refresh [args] Refresh upstream catalogs + indexes. Replaces
|
|
244
250
|
prefetch + refresh + build-indexes.
|
|
251
|
+
--network (v0.11.14) fetch latest signed
|
|
252
|
+
catalog snapshot from npm registry,
|
|
253
|
+
verify against local keys/public.pem,
|
|
254
|
+
swap data/ in place (no CLI/lib reload)
|
|
255
|
+
--prefetch populate offline cache
|
|
256
|
+
--from-cache consume offline cache
|
|
257
|
+
--indexes-only rebuild indexes only
|
|
245
258
|
|
|
246
259
|
v0.10.x compatibility (will be removed in v0.12)
|
|
247
260
|
────────────────────────────────────────────────
|
|
@@ -321,6 +334,35 @@ function main() {
|
|
|
321
334
|
process.exit(0);
|
|
322
335
|
}
|
|
323
336
|
if (cmd === "path") {
|
|
337
|
+
// v0.11.14 (#130): `path copy` was silently consuming the `copy` arg and
|
|
338
|
+
// printing the path. Operators on Windows / Linux saw no clipboard write.
|
|
339
|
+
// Now: implement clipboard copy on the three host platforms (clip on
|
|
340
|
+
// Windows, pbcopy on macOS, xclip|wl-copy|xsel on Linux). If no usable
|
|
341
|
+
// tool is found, fall through to print + stderr-warn (so STDOUT still
|
|
342
|
+
// gives the path for shell consumers like `cd "$(exceptd path)"`).
|
|
343
|
+
const wantCopy = rest.includes("copy") || rest.includes("--copy");
|
|
344
|
+
if (wantCopy) {
|
|
345
|
+
const { spawnSync } = require("child_process");
|
|
346
|
+
const platform = process.platform;
|
|
347
|
+
const candidates = platform === "win32" ? [["clip"]]
|
|
348
|
+
: platform === "darwin" ? [["pbcopy"]]
|
|
349
|
+
: [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-bi"]];
|
|
350
|
+
let copied = false;
|
|
351
|
+
let tried = [];
|
|
352
|
+
for (const [bin, ...argv] of candidates) {
|
|
353
|
+
tried.push(bin);
|
|
354
|
+
const res = spawnSync(bin, argv, { input: PKG_ROOT, encoding: "utf8" });
|
|
355
|
+
if (res.status === 0 && !res.error) { copied = true; break; }
|
|
356
|
+
}
|
|
357
|
+
if (copied) {
|
|
358
|
+
process.stderr.write(`[exceptd path] copied to clipboard: ${PKG_ROOT}\n`);
|
|
359
|
+
process.stdout.write(PKG_ROOT + "\n");
|
|
360
|
+
process.exit(0);
|
|
361
|
+
}
|
|
362
|
+
process.stderr.write(`[exceptd path] copy: no clipboard tool available (tried: ${tried.join(", ")}). Path printed to stdout instead.\n`);
|
|
363
|
+
process.stdout.write(PKG_ROOT + "\n");
|
|
364
|
+
process.exit(0);
|
|
365
|
+
}
|
|
324
366
|
process.stdout.write(PKG_ROOT + "\n");
|
|
325
367
|
process.exit(0);
|
|
326
368
|
}
|
|
@@ -356,12 +398,22 @@ function main() {
|
|
|
356
398
|
// here so the deprecation pointer actually works.
|
|
357
399
|
let effectiveCmd = cmd;
|
|
358
400
|
let effectiveRest = rest;
|
|
359
|
-
if (cmd === "refresh" && rest.includes("--no-network")) {
|
|
401
|
+
if (cmd === "refresh" && (rest.includes("--no-network") || rest.includes("--prefetch"))) {
|
|
402
|
+
// v0.11.14 (#129): --prefetch is the operator-facing name for the
|
|
403
|
+
// cache-population path. --no-network retained as alias for back-compat.
|
|
360
404
|
effectiveCmd = "prefetch";
|
|
361
|
-
effectiveRest = rest.filter(a => a !== "--no-network");
|
|
405
|
+
effectiveRest = rest.filter(a => a !== "--no-network" && a !== "--prefetch");
|
|
362
406
|
} else if (cmd === "refresh" && rest.includes("--indexes-only")) {
|
|
363
407
|
effectiveCmd = "build-indexes";
|
|
364
408
|
effectiveRest = rest.filter(a => a !== "--indexes-only");
|
|
409
|
+
} else if (cmd === "refresh" && rest.includes("--network")) {
|
|
410
|
+
// v0.11.14: --network fetches a fresh signed catalog snapshot from the
|
|
411
|
+
// maintainer's npm-published tarball, verifies signatures against the
|
|
412
|
+
// public key already shipped in the operator's install, and swaps
|
|
413
|
+
// data/ in place. Same trust boundary as `npm update -g`; fresher
|
|
414
|
+
// data slice without requiring a full package upgrade.
|
|
415
|
+
effectiveCmd = "refresh-network";
|
|
416
|
+
effectiveRest = rest.filter(a => a !== "--network");
|
|
365
417
|
}
|
|
366
418
|
|
|
367
419
|
const resolver = COMMANDS[effectiveCmd];
|
|
@@ -594,10 +646,55 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
594
646
|
case "ci": return cmdCi(runner, args, runOpts, pretty);
|
|
595
647
|
}
|
|
596
648
|
} catch (e) {
|
|
649
|
+
// v0.11.14 (#131): when the operator typed a skill name (kernel-lpe-triage)
|
|
650
|
+
// and got "Playbook not found," surface the playbooks that load that skill.
|
|
651
|
+
// 13 playbooks vs 38 skills with many-to-many: operators routinely confuse
|
|
652
|
+
// the two because the website (and AGENTS.md) describe both as runnable.
|
|
653
|
+
const m = e && e.message && e.message.match(/^Playbook not found: ([^\s(]+)/);
|
|
654
|
+
if (m) {
|
|
655
|
+
const wanted = m[1];
|
|
656
|
+
const hint = buildSkillToPlaybookHint(runner, wanted);
|
|
657
|
+
if (hint) {
|
|
658
|
+
return emitError(`Playbook not found: "${wanted}". ${hint}`, { verb: cmd, wanted, type: "playbook_not_found" }, pretty);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
597
661
|
emitError(e.message, { verb: cmd }, pretty);
|
|
598
662
|
}
|
|
599
663
|
}
|
|
600
664
|
|
|
665
|
+
function buildSkillToPlaybookHint(runner, wanted) {
|
|
666
|
+
try {
|
|
667
|
+
const ids = runner.listPlaybooks ? runner.listPlaybooks() : [];
|
|
668
|
+
const matches = [];
|
|
669
|
+
for (const id of ids) {
|
|
670
|
+
let pb;
|
|
671
|
+
try { pb = runner.loadPlaybook(id); } catch { continue; }
|
|
672
|
+
const skills = new Set();
|
|
673
|
+
const collect = (val) => {
|
|
674
|
+
if (Array.isArray(val)) val.forEach(collect);
|
|
675
|
+
else if (val && typeof val === "object") Object.values(val).forEach(collect);
|
|
676
|
+
else if (typeof val === "string") skills.add(val);
|
|
677
|
+
};
|
|
678
|
+
collect(pb.phases?.govern?.skill_preload);
|
|
679
|
+
for (const d of (pb.directives || [])) {
|
|
680
|
+
collect(d.phase_overrides?.govern?.skill_preload);
|
|
681
|
+
}
|
|
682
|
+
if (skills.has(wanted)) matches.push(id);
|
|
683
|
+
}
|
|
684
|
+
if (matches.length > 0) {
|
|
685
|
+
return `That is a SKILL (read-only knowledge unit), not a PLAYBOOK (executable). Skill "${wanted}" is loaded by playbook${matches.length === 1 ? "" : "s"}: ${matches.join(", ")}. ` +
|
|
686
|
+
`To execute: \`exceptd run ${matches[0]}\`. To read the skill: \`exceptd skill ${wanted}\`. ` +
|
|
687
|
+
`Tip: \`exceptd plan\` lists all 13 playbooks; \`exceptd watchlist\` lists skills.`;
|
|
688
|
+
}
|
|
689
|
+
// No matching skill either — provide nearest-playbook suggestions.
|
|
690
|
+
const near = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
|
|
691
|
+
if (near.length > 0) {
|
|
692
|
+
return `Did you mean: ${near.join(", ")}? Run \`exceptd plan\` for the full list.`;
|
|
693
|
+
}
|
|
694
|
+
return `Run \`exceptd plan\` to list the 13 playbooks.`;
|
|
695
|
+
} catch { return null; }
|
|
696
|
+
}
|
|
697
|
+
|
|
601
698
|
function printPlaybookVerbHelp(verb) {
|
|
602
699
|
const cmds = {
|
|
603
700
|
plan: `plan — list playbooks + directives, grouped by scope.
|
|
@@ -1303,7 +1400,30 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1303
1400
|
}
|
|
1304
1401
|
}
|
|
1305
1402
|
|
|
1403
|
+
// v0.11.14: opt-in `--upstream-check` queries the npm registry BEFORE
|
|
1404
|
+
// detect to warn operators if their local catalog is behind the latest
|
|
1405
|
+
// published version. Opt-in so the runner stays offline by default.
|
|
1406
|
+
// Network bounded by an 8s timeout; degrades gracefully when offline.
|
|
1407
|
+
let upstreamCheck = null;
|
|
1408
|
+
if (args["upstream-check"]) {
|
|
1409
|
+
try {
|
|
1410
|
+
const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
|
|
1411
|
+
const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
|
|
1412
|
+
encoding: "utf8",
|
|
1413
|
+
cwd: PKG_ROOT,
|
|
1414
|
+
timeout: 8000,
|
|
1415
|
+
});
|
|
1416
|
+
try { upstreamCheck = JSON.parse((res.stdout || "").trim()); } catch { /* fall through */ }
|
|
1417
|
+
if (upstreamCheck && upstreamCheck.behind) {
|
|
1418
|
+
process.stderr.write(`[exceptd run --upstream-check] STALE: local v${upstreamCheck.local_version} < published v${upstreamCheck.latest_version} (published ${upstreamCheck.latest_published_at}, ${upstreamCheck.days_since_latest_publish}d ago). Continuing with local catalog. Run \`npm update -g @blamejs/exceptd-skills\` or \`exceptd refresh --network\` to consume the latest.\n`);
|
|
1419
|
+
}
|
|
1420
|
+
} catch (e) {
|
|
1421
|
+
upstreamCheck = { ok: false, error: e.message, source: "offline" };
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1306
1425
|
const result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
1426
|
+
if (result && upstreamCheck) result.upstream_check = upstreamCheck;
|
|
1307
1427
|
|
|
1308
1428
|
// v0.11.9 (#113/#114): surface --operator and --ack in the run result so
|
|
1309
1429
|
// operators see the attribution + consent state without inspecting the
|
|
@@ -2699,6 +2819,41 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2699
2819
|
}
|
|
2700
2820
|
}
|
|
2701
2821
|
|
|
2822
|
+
// v0.11.14: opt-in `--registry-check` queries the npm registry for the
|
|
2823
|
+
// latest published version + publish date and computes "days behind."
|
|
2824
|
+
// Opt-in (not on every doctor invocation) so offline use + air-gap
|
|
2825
|
+
// workflows aren't disturbed. Routed through a child process to keep
|
|
2826
|
+
// cmdDoctor synchronous + bound the network timeout cleanly.
|
|
2827
|
+
if (args["registry-check"]) {
|
|
2828
|
+
try {
|
|
2829
|
+
const cliPath = path.join(PKG_ROOT, "lib", "upstream-check-cli.js");
|
|
2830
|
+
const res = spawnSync(process.execPath, [cliPath, "--timeout", "5000"], {
|
|
2831
|
+
encoding: "utf8",
|
|
2832
|
+
cwd: PKG_ROOT,
|
|
2833
|
+
timeout: 8000,
|
|
2834
|
+
});
|
|
2835
|
+
let parsed = null;
|
|
2836
|
+
try { parsed = JSON.parse((res.stdout || "").trim()); } catch { /* fall through */ }
|
|
2837
|
+
if (parsed) {
|
|
2838
|
+
checks.registry = {
|
|
2839
|
+
ok: parsed.ok && (parsed.same || parsed.ahead),
|
|
2840
|
+
severity: parsed.behind ? "warn" : (parsed.ok ? "info" : "warn"),
|
|
2841
|
+
...parsed,
|
|
2842
|
+
};
|
|
2843
|
+
} else {
|
|
2844
|
+
checks.registry = {
|
|
2845
|
+
ok: false,
|
|
2846
|
+
severity: "warn",
|
|
2847
|
+
error: "upstream-check did not return JSON",
|
|
2848
|
+
exit_code: res.status,
|
|
2849
|
+
raw: ((res.stderr || res.stdout || "")).slice(0, 200),
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
} catch (e) {
|
|
2853
|
+
checks.registry = { ok: false, severity: "warn", error: e.message };
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2702
2857
|
// Walk every check and split: errors (severity error/missing/fail) vs warnings
|
|
2703
2858
|
// (severity warn). all_green is true ONLY when zero errors AND zero warnings.
|
|
2704
2859
|
const warnList = [];
|
|
@@ -3449,30 +3604,34 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3449
3604
|
} else {
|
|
3450
3605
|
emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
|
|
3451
3606
|
}
|
|
3607
|
+
// v0.11.14 (#134): exit-code matrix with BLOCKED before FAIL.
|
|
3608
|
+
// Pre-0.11.14 the `if (fail)` check fired first for blocked runs (because
|
|
3609
|
+
// the loop pushed blocked entries onto failReasons), so blocked runs got
|
|
3610
|
+
// exit 2 (FAIL/detected) instead of exit 4 (BLOCKED/didn't-execute).
|
|
3611
|
+
// Operators wiring CI gates couldn't distinguish "playbook detected a
|
|
3612
|
+
// problem" from "playbook never executed because preflight halted."
|
|
3613
|
+
//
|
|
3614
|
+
// Exit-code matrix (final, documented in --help):
|
|
3615
|
+
// 0 PASS every playbook produced a result, none detected/escalating
|
|
3616
|
+
// 1 FRAMEWORK engine/parse error (set by emit() when body is ok:false)
|
|
3617
|
+
// 2 FAIL detected classification OR rwep>=escalate
|
|
3618
|
+
// 3 NO-DATA ran but no --evidence and all inconclusive
|
|
3619
|
+
// 4 BLOCKED at least one playbook returned ok:false (preflight halt,
|
|
3620
|
+
// stale threat intel, missing precondition, mutex contention)
|
|
3621
|
+
// Precedence: BLOCKED > FAIL > NO-DATA > PASS. A blocked playbook didn't
|
|
3622
|
+
// actually evaluate signals, so it can't be a true detection.
|
|
3623
|
+
if (summary.blocked > 0) {
|
|
3624
|
+
const blockedReasons = failReasons.filter(r => r.includes("blocked"));
|
|
3625
|
+
process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) halted before detect. Exit 4. Reasons:\n ${blockedReasons.join("\n ")}\n`);
|
|
3626
|
+
process.exitCode = 4;
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3452
3629
|
if (fail) {
|
|
3453
3630
|
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
3454
|
-
// v0.11.11:
|
|
3455
|
-
// structured stdout JSON has a chance to flush when stdout is piped.
|
|
3456
|
-
// process.exit() can truncate buffered async stdout writes.
|
|
3631
|
+
// v0.11.11: exitCode + return so emit()'s stdout flushes.
|
|
3457
3632
|
process.exitCode = 2;
|
|
3458
3633
|
return;
|
|
3459
3634
|
}
|
|
3460
|
-
// v0.11.12 (#125): ci exit code matrix. Pre-0.11.12 every non-detected
|
|
3461
|
-
// path exited 0 including blocked runs that never executed — CI gates
|
|
3462
|
-
// couldn't distinguish ok:false from ok:true. Now:
|
|
3463
|
-
// 0 PASS — every playbook produced a result, none detected/escalating
|
|
3464
|
-
// 2 FAIL — at least one detected or rwep>=escalate (above)
|
|
3465
|
-
// 3 NO-DATA — ran but no --evidence and all inconclusive
|
|
3466
|
-
// 4 BLOCKED — at least one playbook returned ok:false (preflight halt,
|
|
3467
|
-
// stale threat intel, missing precondition, mutex contention, etc.)
|
|
3468
|
-
// 1 FRAMEWORK — engine/parse error (set elsewhere)
|
|
3469
|
-
// BLOCKED takes precedence over NO-DATA because a blocked run is a
|
|
3470
|
-
// harder gate failure than "no real data."
|
|
3471
|
-
if (summary.blocked > 0) {
|
|
3472
|
-
process.stderr.write(`[exceptd ci] BLOCKED: ${summary.blocked}/${summary.total} playbook(s) returned ok:false (preflight/mutex/threat-currency/etc.). Exit 4. Inspect results[].reason for each blocked entry.\n`);
|
|
3473
|
-
process.exitCode = 4;
|
|
3474
|
-
return;
|
|
3475
|
-
}
|
|
3476
3635
|
const suppliedEvidence = args.evidence || args["evidence-dir"];
|
|
3477
3636
|
const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
|
|
3478
3637
|
if (!suppliedEvidence && allInconclusive) {
|
package/data/_indexes/_meta.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": "1.1.0",
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-13T02:04:13.785Z",
|
|
4
4
|
"generator": "scripts/build-indexes.js",
|
|
5
5
|
"source_count": 49,
|
|
6
6
|
"source_hashes": {
|
|
7
|
-
"manifest.json": "
|
|
7
|
+
"manifest.json": "75707bfee79c57f6d7c6999c9da7292a574cb33669b17cf60e32160a5a2fa0d2",
|
|
8
8
|
"data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
|
|
9
9
|
"data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
|
|
10
10
|
"data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",
|
package/keys/public.pem
CHANGED
package/lib/refresh-external.js
CHANGED
|
@@ -77,22 +77,38 @@ function parseArgs(argv) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function printHelp() {
|
|
80
|
-
console.log(`refresh
|
|
80
|
+
console.log(`refresh — pull latest upstream data, optionally upsert into local catalogs.
|
|
81
|
+
|
|
82
|
+
Default behavior is to actually fetch from the network in dry-run mode and write
|
|
83
|
+
refresh-report.json. Use --apply to upsert findings into local catalogs.
|
|
81
84
|
|
|
82
85
|
Modes:
|
|
83
|
-
(default)
|
|
84
|
-
--apply apply diffs and rebuild indexes
|
|
85
|
-
--
|
|
86
|
-
|
|
86
|
+
(default) fetch all sources from network, dry-run, write refresh-report.json
|
|
87
|
+
--apply apply diffs and rebuild indexes (default also fetches; combine)
|
|
88
|
+
--network fetch the latest signed catalog snapshot from the
|
|
89
|
+
maintainer's npm-published tarball, verify every skill
|
|
90
|
+
signature against the local public.pem, swap data/ in
|
|
91
|
+
place. Same trust anchor as \`npm update -g\`, only the
|
|
92
|
+
data slice changes — useful when you want fresher
|
|
93
|
+
intel without re-resolving CLI/lib code.
|
|
94
|
+
--prefetch (alias: --no-network) populate the cache for offline use.
|
|
95
|
+
Equivalent to \`exceptd prefetch\`.
|
|
87
96
|
--from-cache [<p>] read from prefetch cache (default .cache/upstream).
|
|
88
97
|
Combine with --apply to upsert against cached data
|
|
89
|
-
entirely offline.
|
|
90
|
-
--
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
entirely offline. Cache must be pre-populated via --prefetch.
|
|
99
|
+
--source kev,epss scope to a comma-separated list (kev|epss|nvd|rfc|pins)
|
|
100
|
+
--from-fixture <p> use frozen fixture payloads (tests use this path)
|
|
101
|
+
--indexes-only rebuild data/_indexes/ only; no network. Equivalent to
|
|
102
|
+
\`exceptd refresh --indexes-only\`.
|
|
103
|
+
--swarm fan out sources across worker threads. Best with --from-cache.
|
|
104
|
+
|
|
105
|
+
Air-gap workflow:
|
|
106
|
+
1. On a connected host: \`exceptd refresh --prefetch\`
|
|
107
|
+
2. Copy .cache/upstream/ across the boundary
|
|
108
|
+
3. On the offline host: \`exceptd refresh --from-cache --apply\`
|
|
93
109
|
|
|
94
110
|
Outputs:
|
|
95
|
-
refresh-report.json (gitignored) —
|
|
111
|
+
refresh-report.json (gitignored) — per-source status + every diff
|
|
96
112
|
|
|
97
113
|
This module never auto-applies version-pin bumps — those require audit per
|
|
98
114
|
AGENTS.md Hard Rule #12 and are surfaced as report-only findings.
|
|
@@ -642,7 +658,19 @@ function loadCtx(opts) {
|
|
|
642
658
|
const abs = path.resolve(opts.fromCache);
|
|
643
659
|
ctx.cacheDir = abs;
|
|
644
660
|
if (!fs.existsSync(abs)) {
|
|
645
|
-
|
|
661
|
+
// v0.11.14 (#129): operators following the website's air-gap workflow
|
|
662
|
+
// hit this with an unhelpful "path does not exist" stack trace. The
|
|
663
|
+
// cache is populated by `exceptd refresh --no-network` (which routes
|
|
664
|
+
// to prefetch). Tell them exactly that, and emit a structured JSON
|
|
665
|
+
// error to stderr instead of a fatal stack trace.
|
|
666
|
+
const err = new Error(
|
|
667
|
+
`refresh: --from-cache path does not exist: ${abs}\n` +
|
|
668
|
+
`Hint: the cache is populated by running \`exceptd refresh --no-network\` (or \`exceptd refresh --prefetch\`) ` +
|
|
669
|
+
`on a connected host first. Air-gap workflow: (1) on connected host: \`exceptd refresh --no-network\`, ` +
|
|
670
|
+
`(2) copy .cache/upstream/ across the boundary, (3) on offline host: \`exceptd refresh --from-cache --apply\`.`
|
|
671
|
+
);
|
|
672
|
+
err._exceptd_hint = true;
|
|
673
|
+
throw err;
|
|
646
674
|
}
|
|
647
675
|
}
|
|
648
676
|
return ctx;
|
|
@@ -769,7 +797,14 @@ async function sequential(items, fn) {
|
|
|
769
797
|
|
|
770
798
|
if (require.main === module) {
|
|
771
799
|
main().catch((err) => {
|
|
772
|
-
|
|
800
|
+
// v0.11.14 (#129): hinted errors print the hint message + a structured
|
|
801
|
+
// JSON line on stderr instead of a fatal stack trace.
|
|
802
|
+
if (err && err._exceptd_hint) {
|
|
803
|
+
console.error(err.message);
|
|
804
|
+
console.error(JSON.stringify({ ok: false, error: err.message.split("\n")[0], hint: err.message.split("\n").slice(1).join(" ").trim(), verb: "refresh" }));
|
|
805
|
+
} else {
|
|
806
|
+
console.error(`refresh-external: fatal: ${err && err.stack ? err.stack : err}`);
|
|
807
|
+
}
|
|
773
808
|
process.exit(2);
|
|
774
809
|
});
|
|
775
810
|
}
|