@blamejs/exceptd-skills 0.11.12 → 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 +57 -0
- package/bin/exceptd.js +231 -41
- 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,62 @@
|
|
|
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
|
+
|
|
42
|
+
## 0.11.13 — 2026-05-13
|
|
43
|
+
|
|
44
|
+
**Patch: the final two stragglers — universal `ok:false` exit and empty-submission diff counters.**
|
|
45
|
+
|
|
46
|
+
### Bugs
|
|
47
|
+
|
|
48
|
+
- **#127 (originally #100) — `ok:false` body always yields non-zero exit.** Pre-0.11.13 several verbs emitted a result body with `ok: false` to stdout but didn't set `process.exitCode`, so `exceptd run ...; echo $?` returned 0 and `set -e` shell scripts couldn't gate on it. The previous fix was per-verb. Now `emit()` itself sets `process.exitCode = 1` whenever the body has `ok: false` at top level (unless a caller already set a different non-zero code). Universal contract: anything that emits `ok: false` to stdout OR stderr returns non-zero, no exceptions. New verbs cannot regress this — the catch is at the renderer.
|
|
49
|
+
|
|
50
|
+
- **#128 (originally #102) — attest diff falls back to playbook catalog when submissions are empty.** Pre-0.11.13 `attest diff` between two identical empty-submission attestations reported `status: unchanged` (hash equality) but `total_compared: 0, unchanged_count: 0` — operators couldn't tell whether "0 unchanged" meant "diff didn't iterate" or "nothing to compare." Now: when a submission has neither `artifacts` nor `observations`, the diff helper falls back to the playbook's `look.artifacts` catalog (via the attestation's stored `playbook_id`). Result: `total_compared` reflects the catalog size; `unchanged_count` equals `total_compared` when both sides are uniformly empty. Real observation submissions retain the prior behavior.
|
|
51
|
+
|
|
52
|
+
### Tests
|
|
53
|
+
|
|
54
|
+
3 new regression cases. 347 total. The `#127` test asserts the universal contract by hitting `attest verify` on a non-existent session id and checking that any `ok:false` body (stdout or stderr) maps to non-zero exit. The `#128` test runs two `{}` submissions through `run sbom` and asserts the diff reports `total_compared > 0` matching `unchanged_count`.
|
|
55
|
+
|
|
56
|
+
### Lesson codified in CLAUDE.md
|
|
57
|
+
|
|
58
|
+
When a class of bug ("verb forgot to set exit code") keeps recurring across releases, fix the class, not the instance. Move the contract to the lowest layer that all paths share — here, `emit()` itself.
|
|
59
|
+
|
|
3
60
|
## 0.11.12 — 2026-05-12
|
|
4
61
|
|
|
5
62
|
**Patch: items 123-126 — content-not-just-shape, exit-code discipline, diff iteration.**
|
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];
|
|
@@ -448,6 +500,16 @@ function emit(obj, pretty, humanRenderer) {
|
|
|
448
500
|
// default (no flag, renderer present) → HUMAN
|
|
449
501
|
// default (no flag, no renderer) → indented JSON when TTY else compact
|
|
450
502
|
// This closes the longest-standing UX gap across 8 releases.
|
|
503
|
+
//
|
|
504
|
+
// v0.11.13 (#127): emit() now ALSO sets process.exitCode = 1 when the body
|
|
505
|
+
// carries `ok: false` at top level (unless a caller already set a different
|
|
506
|
+
// non-zero exitCode). Pre-0.11.13 verbs that emitted ok:false to stdout
|
|
507
|
+
// without explicitly setting the exit code returned 0, defeating `set -e`
|
|
508
|
+
// and CI gates. The previous fix was per-verb; this is a universal catch
|
|
509
|
+
// so new verbs / new ok:false paths can't regress the contract.
|
|
510
|
+
if (obj && obj.ok === false && !process.exitCode) {
|
|
511
|
+
process.exitCode = 1;
|
|
512
|
+
}
|
|
451
513
|
const wantJson = !!global.__exceptdWantJson || !!process.env.EXCEPTD_RAW_JSON;
|
|
452
514
|
if (humanRenderer && !wantJson && !pretty) {
|
|
453
515
|
process.stdout.write(humanRenderer(obj) + "\n");
|
|
@@ -584,10 +646,55 @@ function dispatchPlaybook(cmd, argv) {
|
|
|
584
646
|
case "ci": return cmdCi(runner, args, runOpts, pretty);
|
|
585
647
|
}
|
|
586
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
|
+
}
|
|
587
661
|
emitError(e.message, { verb: cmd }, pretty);
|
|
588
662
|
}
|
|
589
663
|
}
|
|
590
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
|
+
|
|
591
698
|
function printPlaybookVerbHelp(verb) {
|
|
592
699
|
const cmds = {
|
|
593
700
|
plan: `plan — list playbooks + directives, grouped by scope.
|
|
@@ -1293,7 +1400,30 @@ function cmdRun(runner, args, runOpts, pretty) {
|
|
|
1293
1400
|
}
|
|
1294
1401
|
}
|
|
1295
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
|
+
|
|
1296
1425
|
const result = runner.run(playbookId, directiveId, submission, runOpts);
|
|
1426
|
+
if (result && upstreamCheck) result.upstream_check = upstreamCheck;
|
|
1297
1427
|
|
|
1298
1428
|
// v0.11.9 (#113/#114): surface --operator and --ack in the run result so
|
|
1299
1429
|
// operators see the attribution + consent state without inspecting the
|
|
@@ -2206,16 +2336,35 @@ function cmdAttest(runner, args, runOpts, pretty) {
|
|
|
2206
2336
|
* canonical nested view of both shapes lets `attest diff` produce meaningful
|
|
2207
2337
|
* counts regardless of which shape the operator submitted.
|
|
2208
2338
|
*/
|
|
2339
|
+
function _playbookArtifactCatalog(runner, playbookId) {
|
|
2340
|
+
if (!playbookId) return null;
|
|
2341
|
+
try {
|
|
2342
|
+
const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
|
|
2343
|
+
if (!pb) return null;
|
|
2344
|
+
const arts = (pb.phases?.look?.artifacts || []).filter(a => a && a.id);
|
|
2345
|
+
if (arts.length === 0) return null;
|
|
2346
|
+
return Object.fromEntries(arts.map(a => [a.id, { captured: false, _catalog_stub: true }]));
|
|
2347
|
+
} catch { return null; }
|
|
2348
|
+
}
|
|
2349
|
+
function _playbookSignalCatalog(runner, playbookId) {
|
|
2350
|
+
if (!playbookId) return null;
|
|
2351
|
+
try {
|
|
2352
|
+
const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
|
|
2353
|
+
if (!pb) return null;
|
|
2354
|
+
const inds = (pb.phases?.look?.indicators || []).filter(i => i && i.id);
|
|
2355
|
+
if (inds.length === 0) return null;
|
|
2356
|
+
return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
|
|
2357
|
+
} catch { return null; }
|
|
2358
|
+
}
|
|
2209
2359
|
function normalizedArtifacts(submission, runner, playbookId) {
|
|
2210
|
-
if (!submission || typeof submission !== "object")
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
-
// key as its own artifact id with `{ captured: true, value: <indicator|value> }`.
|
|
2360
|
+
if (!submission || typeof submission !== "object") {
|
|
2361
|
+
return _playbookArtifactCatalog(runner, playbookId) || {};
|
|
2362
|
+
}
|
|
2363
|
+
if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
|
|
2364
|
+
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
2365
|
+
// v0.11.12 (#126): load real playbook so look.artifacts catalog can map
|
|
2366
|
+
// observations. v0.11.13 (#128): when normalize succeeds but produces an
|
|
2367
|
+
// empty map, fall through to direct mapping instead of returning empty.
|
|
2219
2368
|
if (playbookId) {
|
|
2220
2369
|
try {
|
|
2221
2370
|
const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
|
|
@@ -2223,23 +2372,26 @@ function normalizedArtifacts(submission, runner, playbookId) {
|
|
|
2223
2372
|
const norm = runner.normalizeSubmission({ observations: submission.observations }, pb);
|
|
2224
2373
|
if (norm && norm.artifacts && Object.keys(norm.artifacts).length > 0) return norm.artifacts;
|
|
2225
2374
|
}
|
|
2226
|
-
} catch { /* fall through
|
|
2375
|
+
} catch { /* fall through */ }
|
|
2227
2376
|
}
|
|
2228
|
-
// Direct mapping: observation keys are the artifact ids by convention.
|
|
2229
2377
|
const out = {};
|
|
2230
2378
|
for (const [k, v] of Object.entries(submission.observations)) {
|
|
2231
2379
|
out[k] = (v && typeof v === "object") ? v : { value: v };
|
|
2232
2380
|
}
|
|
2233
2381
|
return out;
|
|
2234
2382
|
}
|
|
2235
|
-
|
|
2383
|
+
// v0.11.13 (#128): empty submission ({} or {observations:{}}). Identical
|
|
2384
|
+
// hashes still mean "no operator data was supplied, same on both sides."
|
|
2385
|
+
// Fall back to the playbook's look.artifacts catalog so total_compared
|
|
2386
|
+
// reflects "N catalog artifacts, all uniformly empty on both sides."
|
|
2387
|
+
return _playbookArtifactCatalog(runner, playbookId) || {};
|
|
2236
2388
|
}
|
|
2237
2389
|
function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
2238
|
-
if (!submission || typeof submission !== "object")
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2390
|
+
if (!submission || typeof submission !== "object") {
|
|
2391
|
+
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
2392
|
+
}
|
|
2393
|
+
if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
|
|
2394
|
+
if (submission.observations && Object.keys(submission.observations).length > 0) {
|
|
2243
2395
|
if (playbookId) {
|
|
2244
2396
|
try {
|
|
2245
2397
|
const pb = runner.loadPlaybook ? runner.loadPlaybook(playbookId) : null;
|
|
@@ -2247,16 +2399,15 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
|
|
|
2247
2399
|
const norm = runner.normalizeSubmission({ observations: submission.observations }, pb);
|
|
2248
2400
|
if (norm && norm.signal_overrides && Object.keys(norm.signal_overrides).length > 0) return norm.signal_overrides;
|
|
2249
2401
|
}
|
|
2250
|
-
} catch { /* fall through
|
|
2402
|
+
} catch { /* fall through */ }
|
|
2251
2403
|
}
|
|
2252
|
-
// Direct mapping: observation key -> result (canonical hit/miss/inconclusive).
|
|
2253
2404
|
const out = {};
|
|
2254
2405
|
for (const [k, v] of Object.entries(submission.observations)) {
|
|
2255
2406
|
if (v && typeof v === "object" && v.result !== undefined) out[k] = v.result;
|
|
2256
2407
|
}
|
|
2257
2408
|
return out;
|
|
2258
2409
|
}
|
|
2259
|
-
return {};
|
|
2410
|
+
return _playbookSignalCatalog(runner, playbookId) || {};
|
|
2260
2411
|
}
|
|
2261
2412
|
|
|
2262
2413
|
/**
|
|
@@ -2668,6 +2819,41 @@ function cmdDoctor(runner, args, runOpts, pretty) {
|
|
|
2668
2819
|
}
|
|
2669
2820
|
}
|
|
2670
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
|
+
|
|
2671
2857
|
// Walk every check and split: errors (severity error/missing/fail) vs warnings
|
|
2672
2858
|
// (severity warn). all_green is true ONLY when zero errors AND zero warnings.
|
|
2673
2859
|
const warnList = [];
|
|
@@ -3418,30 +3604,34 @@ function cmdCi(runner, args, runOpts, pretty) {
|
|
|
3418
3604
|
} else {
|
|
3419
3605
|
emit({ verb: "ci", session_id: sessionId, playbooks_run: ids, summary, results }, pretty);
|
|
3420
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
|
+
}
|
|
3421
3629
|
if (fail) {
|
|
3422
3630
|
process.stderr.write(`[exceptd ci] FAIL: ${failReasons.join("; ")}\n`);
|
|
3423
|
-
// v0.11.11:
|
|
3424
|
-
// structured stdout JSON has a chance to flush when stdout is piped.
|
|
3425
|
-
// process.exit() can truncate buffered async stdout writes.
|
|
3631
|
+
// v0.11.11: exitCode + return so emit()'s stdout flushes.
|
|
3426
3632
|
process.exitCode = 2;
|
|
3427
3633
|
return;
|
|
3428
3634
|
}
|
|
3429
|
-
// v0.11.12 (#125): ci exit code matrix. Pre-0.11.12 every non-detected
|
|
3430
|
-
// path exited 0 including blocked runs that never executed — CI gates
|
|
3431
|
-
// couldn't distinguish ok:false from ok:true. Now:
|
|
3432
|
-
// 0 PASS — every playbook produced a result, none detected/escalating
|
|
3433
|
-
// 2 FAIL — at least one detected or rwep>=escalate (above)
|
|
3434
|
-
// 3 NO-DATA — ran but no --evidence and all inconclusive
|
|
3435
|
-
// 4 BLOCKED — at least one playbook returned ok:false (preflight halt,
|
|
3436
|
-
// stale threat intel, missing precondition, mutex contention, etc.)
|
|
3437
|
-
// 1 FRAMEWORK — engine/parse error (set elsewhere)
|
|
3438
|
-
// BLOCKED takes precedence over NO-DATA because a blocked run is a
|
|
3439
|
-
// harder gate failure than "no real data."
|
|
3440
|
-
if (summary.blocked > 0) {
|
|
3441
|
-
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`);
|
|
3442
|
-
process.exitCode = 4;
|
|
3443
|
-
return;
|
|
3444
|
-
}
|
|
3445
3635
|
const suppliedEvidence = args.evidence || args["evidence-dir"];
|
|
3446
3636
|
const allInconclusive = summary.inconclusive === summary.total && summary.total > 0;
|
|
3447
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
|
}
|