@blamejs/exceptd-skills 0.18.6 → 0.18.7

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/bin/exceptd.js +261 -56
  3. package/data/_indexes/_meta.json +22 -3
  4. package/data/cve-catalog.json +25 -0
  5. package/data/playbooks/framework.json +2 -2
  6. package/data/playbooks/post-quantum-migration.json +1 -1
  7. package/lib/auto-discovery.js +30 -10
  8. package/lib/collectors/ai-api.js +9 -2
  9. package/lib/collectors/cicd-pipeline-compromise.js +24 -5
  10. package/lib/collectors/cred-stores.js +17 -4
  11. package/lib/collectors/crypto.js +9 -2
  12. package/lib/collectors/hardening.js +9 -2
  13. package/lib/collectors/library-author.js +24 -3
  14. package/lib/collectors/mcp.js +9 -2
  15. package/lib/collectors/runtime.js +9 -2
  16. package/lib/collectors/sbom.js +28 -15
  17. package/lib/collectors/scan-excludes.js +25 -0
  18. package/lib/collectors/secrets.js +40 -4
  19. package/lib/cve-curation.js +84 -8
  20. package/lib/lint-skills.js +75 -3
  21. package/lib/playbook-runner.js +375 -40
  22. package/lib/prefetch.js +6 -1
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +201 -24
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +106 -13
  27. package/lib/sign.js +107 -29
  28. package/lib/source-advisories.js +23 -5
  29. package/lib/source-ghsa.js +25 -1
  30. package/lib/source-osv.js +26 -1
  31. package/lib/upstream-check.js +1 -1
  32. package/lib/validate-cve-catalog.js +19 -3
  33. package/lib/validate-indexes.js +62 -1
  34. package/lib/validate-playbooks.js +4 -1
  35. package/lib/validate-vendor.js +69 -8
  36. package/manifest.json +53 -53
  37. package/orchestrator/index.js +69 -13
  38. package/package.json +1 -1
  39. package/sbom.cdx.json +124 -124
  40. package/scripts/audit-cross-skill.js +1 -1
  41. package/scripts/bootstrap.js +1 -0
  42. package/scripts/build-indexes.js +58 -4
  43. package/scripts/check-agents-md-collectors.js +41 -13
  44. package/scripts/check-changelog-extract.js +4 -4
  45. package/scripts/check-codebase-patterns.js +19 -5
  46. package/scripts/check-manifest-snapshot.js +74 -30
  47. package/scripts/check-sbom-currency.js +25 -5
  48. package/scripts/check-test-count.js +26 -7
  49. package/scripts/check-test-coverage.js +44 -4
  50. package/scripts/check-version-tags.js +27 -8
  51. package/scripts/predeploy.js +1 -1
  52. package/scripts/refresh-manifest-snapshot.js +14 -4
  53. package/scripts/refresh-reverse-refs.js +7 -1
  54. package/scripts/refresh-sbom.js +1 -1
  55. package/scripts/release.js +3 -3
  56. package/scripts/run-e2e-scenarios.js +18 -8
  57. package/scripts/validate-vendor-online.js +20 -2
  58. package/scripts/verify-shipped-tarball.js +65 -6
  59. package/sources/validators/cve-validator.js +17 -1
  60. package/vendor/blamejs/_PROVENANCE.json +4 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.18.7 — 2026-06-20
4
+
5
+ Escalation and feeds_into conditions that use an `any`/`all` quantifier prefix, a quoted `matches '…'` pattern, or a quoted `contains` member now evaluate instead of silently failing closed. Several catalog chains that depended on these forms — most visibly the compliance-theater → SBOM correlation — were dead and now fire; the feeds_into evaluation context also exposes the bare `blast_radius_score`, `theater_verdict`, and `compliance_theater_check` tokens the catalog references, so an engine-computed value is no longer lost to a `null` lookup. The SBOM → kernel / MCP / AI-API follow-ups now fire too: a matched CVE carries an `attack_class`, the analyze and close contexts expose the matched-CVE array the `any matched_cve.attack_class == …` rules quantify over, and the CVEs that map unambiguously to one of those deep-dive classes are tagged in the catalog — so an SBOM scan that surfaces an MCP-server RCE or a prompt-injection-to-RCE routes the operator into the matching deep dive, while an unclassified match routes nowhere rather than guessing. `IN […]` member lists, AND/OR joins, and outer parentheses are parsed quote-aware, so a comma, bracket, or operator inside a quoted value no longer tears the clause, and a `contains`/`IN` against a path that doesn't resolve records a diagnostic instead of evaluating to an invisible false.
6
+
7
+ RWEP scoring no longer silently drops the active-exploitation weight when a CVE's `active_exploitation` value is outside the recognised vocabulary or merely stray-cased (`Confirmed`, ` CONFIRMED `): canonical values are case-normalised, and a genuinely unrecognised value contributes zero **and** emits a warning so the dropped weight is visible rather than quietly lowering the priority. Curation now rejects a mixed-shape `rwep_factors` block (post-weight integers with a stray non-numeric value) at apply time instead of letting it route through the wrong scoring path and write a silently-wrong RWEP to the catalog. The scorer's two factor shapes no longer disagree on that block — the derive path applies the same mixed-shape guard the validator does, so it can't fall back to the boolean path and under-score — the Shape-B sum clamps each weight to its ceiling, and an SLA timeline is derived only from a real number rather than treating a NaN as the least-urgent tier.
8
+
9
+ `attest diff` no longer manufactures phantom drift when one attestation carries real evidence and the other is empty — the full-catalog placeholder now stands in only when both sides are empty, so a populated side diffs against nothing and yields the genuine added/removed instead of every catalog id reading as drift. It also no longer masks a real change when an artifact's secret or path is carried under a key other than `value`: that evidence is preserved through capture and compared, so two attestations recording different secrets at different paths no longer diff as unchanged.
10
+
11
+ An empty-string flag value is rejected rather than silently degrading the command. `--evidence ""`, `--evidence-dir ""`, `--phase ""`, `--against ""`, `--playbook ""`, and `--session-id ""` now error instead of running with no scope or bypassing their phase / target / dispatcher validation, and `attest export` redacts `signal_overrides` in its labeled output instead of emitting them verbatim.
12
+
13
+ The secrets collector no longer double-reports an Anthropic `sk-ant-…` key as both an OpenAI and an Anthropic key, and now surfaces subtrees skipped for exceeding the depth cap instead of leaving them silently unscanned. The collectors and the CVE-cache reader read each file through to end-of-file, so a key or misconfiguration sitting past a short-read boundary on a network or sync-backed filesystem is no longer dropped and the scan no longer reports clean over content it never decoded. The library-author collector no longer flags npm-workspaces lockfile links (`link: true`) as missing integrity — a false positive on every workspaces monorepo. The CI/CD collector matches the GitHub OIDC issuer and a `GITHUB_TOKEN` secret name exactly, so a look-alike issuer host or a custom `GITHUB_TOKEN_*` secret is no longer mis-handled.
14
+
15
+ The network-backed pollers (OSV, GHSA, the Datatracker freshness probe, the package prefetch, and the watchlist org scan) retry transient failures with backoff and cap response size instead of buffering an unbounded body into memory; the prefetch retry classifier reads the error status under the field the retry helper sets, so a 429/5xx is retried rather than treated as fatal. The vendored-tree integrity check fails closed when a license hash or a per-file hash is absent — a stripped hash no longer reads as verified.
16
+
17
+ CVE-catalog validation rejects impossible calendar dates (for example `2026-02-30`, or Feb 29 in a non-leap year) on KEV deadline fields instead of letting them roll over to a valid-looking date, and the public-exploit URL classifier is anchored so a look-alike host such as `exploit-db.com.attacker.example` is no longer treated as a public-exploit source.
18
+
19
+ The index-freshness gate now verifies that every derived index file still exists and parses, not only that the source hashes match — a deleted or truncated index no longer passes as current. `refresh --network` and the vendored-file online validator pin their fetches to the npm registry / GitHub hosts, so a tampered metadata response or redirect cannot steer a download at an arbitrary address. `refresh --network` additionally verifies the npm registry's signature over the fetched tarball and authenticates the `data/` tree and manifest snapshot before swapping them in, so a forged metadata response that recomputes the transport hashes is still refused. The live CVE-validation downgrade guard now reads the upstream metric's declared CVSS version, so a legacy v2 score NVD returns without a vector string is still recognised as a downgrade and held back from a curated v3.x entry — matching the cached refresh path, which already used the declared version.
20
+
21
+ Internal: the diff-coverage export extractor and the codebase-pattern detectors are now string-aware, so a brace or `//` inside a string literal can no longer hide a new export from the coverage gate or disarm the process-exit / dynamic-regex detectors. Temp files in the test suite are created inside owner-only directories. The signing-key bootstrap creates each key with an exclusive (`O_EXCL`) open so an existing key is refused atomically rather than via a separate existence check, and the index validator opens derived files with `O_NOFOLLOW` and reads them through the descriptor, removing the check-then-read windows static analysis flagged. The signer schema-validates the manifest before writing a signature, so it can no longer bless a manifest the verifier would reject at install time. Several gate detectors close blind spots: the test-count gate ignores `test(` inside a multiline template literal, the export extractor sees method-shorthand and single-identifier `module.exports`, the process-exit detector covers `scripts/` and `bin/`, the version-tag detector no longer counts an IPv4 octet run, and `build-indexes --changed` regenerates a deleted index instead of trusting a clean source hash and no longer rewrites a timestamp on a no-op run.
22
+
3
23
  ## 0.18.6 — 2026-06-14
4
24
 
5
25
  `attest prune` now ages out sessions that hold only replay records (whose attestation was removed). Such a session had no datable timestamp, so it was kept indefinitely and the attestation store could grow without bound; it is now dated by its newest replay timestamp and pruned past the cutoff like any other session.
package/bin/exceptd.js CHANGED
@@ -60,7 +60,7 @@ const PKG_ROOT = path.resolve(__dirname, "..");
60
60
  // constants so a new verb cannot regress the exit-code contract by typo,
61
61
  // and so the help-text dump (`doctor --exit-codes`) and the runtime
62
62
  // behavior share the same source of truth.
63
- const { EXIT_CODES, listExitCodes } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
63
+ const { EXIT_CODES, listExitCodes, safeExit } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
64
64
  const { validateIdComponent } = require(path.join(PKG_ROOT, "lib", "id-validation.js"));
65
65
  const { suggestFlag, flagsFor, VERB_FLAG_ALLOWLIST } = require(path.join(PKG_ROOT, "lib", "flag-suggest.js"));
66
66
  const codepointClass = require(path.join(PKG_ROOT, "vendor", "blamejs", "codepoint-class.js"));
@@ -612,7 +612,7 @@ function main() {
612
612
  }
613
613
  if (cmd === "version" || cmd === "--version" || cmd === "-v") {
614
614
  process.stdout.write(readPkgVersion() + "\n");
615
- process.exit(0);
615
+ safeExit(EXIT_CODES.SUCCESS); return;
616
616
  }
617
617
  if (cmd === "path") {
618
618
  // v0.11.14 (#130): `path copy` was silently consuming the `copy` arg and
@@ -638,14 +638,14 @@ function main() {
638
638
  if (copied) {
639
639
  process.stderr.write(`[exceptd path] copied to clipboard: ${PKG_ROOT}\n`);
640
640
  process.stdout.write(PKG_ROOT + "\n");
641
- process.exit(0);
641
+ safeExit(EXIT_CODES.SUCCESS); return;
642
642
  }
643
643
  process.stderr.write(`[exceptd path] copy: no clipboard tool available (tried: ${tried.join(", ")}). Path printed to stdout instead.\n`);
644
644
  process.stdout.write(PKG_ROOT + "\n");
645
- process.exit(0);
645
+ safeExit(EXIT_CODES.SUCCESS); return;
646
646
  }
647
647
  process.stdout.write(PKG_ROOT + "\n");
648
- process.exit(0);
648
+ safeExit(EXIT_CODES.SUCCESS); return;
649
649
  }
650
650
 
651
651
  // v0.13.0: hard-refuse the v0.10.x legacy verbs that were
@@ -1157,9 +1157,9 @@ function hasReadableStdin() {
1157
1157
  * on failure so the caller can wrap it with its own verb prefix.
1158
1158
  */
1159
1159
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
1160
- function validateIsoSince(raw) {
1160
+ function validateIsoSince(raw, flagName = "--since") {
1161
1161
  if (typeof raw !== "string" || !ISO_DATE_RE.test(raw) || isNaN(Date.parse(raw))) {
1162
- return `--since must be a parseable ISO-8601 calendar timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(raw)).slice(0, 80)}`;
1162
+ return `${flagName} must be a parseable ISO-8601 calendar timestamp (e.g. 2026-05-01 or 2026-05-01T00:00:00Z). Got: ${JSON.stringify(String(raw)).slice(0, 80)}`;
1163
1163
  }
1164
1164
  return null;
1165
1165
  }
@@ -1207,7 +1207,7 @@ function detectVexShape(doc) {
1207
1207
  // OpenVEX: @context starts with https://openvex.dev AND statements[]
1208
1208
  const ctx = doc["@context"];
1209
1209
  const ctxStr = Array.isArray(ctx) ? ctx[0] : ctx;
1210
- if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev") && Array.isArray(doc.statements)) {
1210
+ if (typeof ctxStr === "string" && ctxStr.startsWith("https://openvex.dev/") && Array.isArray(doc.statements)) {
1211
1211
  return { ok: true, detected: "openvex", top_level_keys: keys };
1212
1212
  }
1213
1213
  // Common false-positive shapes — give the operator a hint.
@@ -1399,13 +1399,20 @@ function dispatchPlaybook(cmd, argv) {
1399
1399
  );
1400
1400
  process.env.EXCEPTD_AIR_GAP_NOTICE_SHOWN = "1";
1401
1401
  }
1402
- if (args["session-id"]) {
1402
+ if (args["session-id"] !== undefined) {
1403
1403
  // --session-id is a filesystem path component (resolves to
1404
1404
  // .exceptd/attestations/<id>/attestation.json). Operator-supplied input
1405
1405
  // with `..` or path separators escapes the attestation root. Route
1406
1406
  // through the shared validateIdComponent('session') helper so the regex
1407
1407
  // + all-dots refusal stay aligned with persistAttestation /
1408
1408
  // validateSessionIdForRead.
1409
+ //
1410
+ // Presence-gated (`!== undefined`), not truthy-gated: `--session-id ""`
1411
+ // / `--session-id=` carry an explicit empty value the operator meant to
1412
+ // pin. A truthy gate skips the validator for "", silently substituting a
1413
+ // random id and discarding the operator's intent. validateIdComponent
1414
+ // rejects "" with "must not be empty", matching the --operator empty
1415
+ // refusal below.
1409
1416
  const sid = args["session-id"];
1410
1417
  const r = validateIdComponent(sid, "session");
1411
1418
  if (!r.ok) {
@@ -2497,6 +2504,11 @@ async function cmdCollect(runner, args, runOpts, pretty) {
2497
2504
  // --cwd <path> overrides process.cwd(). Validated as an existing
2498
2505
  // directory; non-existent / non-directory cwd is operator error.
2499
2506
  let cwd = process.cwd();
2507
+ // An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
2508
+ // scan process.cwd() — the wrong directory — reported as a successful run.
2509
+ if (args.cwd === "") {
2510
+ return emitError(`collect: --cwd was given an empty value; pass an existing directory path`, { verb: "collect", playbook_id: playbookId }, pretty);
2511
+ }
2500
2512
  if (args.cwd) {
2501
2513
  const resolved = path.resolve(String(args.cwd));
2502
2514
  let stat;
@@ -3358,6 +3370,12 @@ function cmdRun(runner, args, runOpts, pretty) {
3358
3370
  // first, then falls back to a strict isTTY===false check only on Windows
3359
3371
  // (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
3360
3372
  // isTTY === false for genuine piped input, so that path still works.
3373
+ // An explicit empty value (`--evidence ""`) is operator error: it would
3374
+ // otherwise be falsy and silently produce a no-evidence "not_detected" run at
3375
+ // exit 0, masking the fact that the intended evidence never loaded.
3376
+ if (args.evidence === "") {
3377
+ return emitError("run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run", { verb: "run" }, pretty);
3378
+ }
3361
3379
  const autoStdin = !args.evidence && hasReadableStdin();
3362
3380
  if (autoStdin) {
3363
3381
  args.evidence = "-";
@@ -4168,6 +4186,20 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4168
4186
  runOpts.session_id = sessionId;
4169
4187
 
4170
4188
  let bundle = {};
4189
+ // An explicit empty value (--evidence "" / --evidence= / an unset shell
4190
+ // variable) is falsy, so the truthiness-gated reads below would skip
4191
+ // entirely, the bundle would stay {}, every playbook would run with no
4192
+ // evidence, and the contract would report a clean not_detected at exit 0 —
4193
+ // a false-clean that hides the fact the operator's intended evidence never
4194
+ // loaded. Mirror the single-playbook run / ci empty-value guards: refuse
4195
+ // the empty value loudly rather than running a vacuous contract. Presence,
4196
+ // not truthiness, is the test.
4197
+ if (args.evidence === "") {
4198
+ return emitError("run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run", { verb: "run", flag: "evidence" }, pretty);
4199
+ }
4200
+ if (args["evidence-dir"] === "") {
4201
+ return emitError("run: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir", { verb: "run", flag: "evidence-dir" }, pretty);
4202
+ }
4171
4203
  if (args.evidence) {
4172
4204
  try { bundle = readEvidence(args.evidence); } catch (e) {
4173
4205
  return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
@@ -4176,11 +4208,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4176
4208
  // --evidence-dir <dir>: each <playbook-id>.json under the directory is read
4177
4209
  // as that playbook's submission. Lets operators wire up one cron job that
4178
4210
  // collects per-playbook evidence into a directory, then runs the whole
4179
- // contract in one pass.
4211
+ // contract in one pass. The empty-string form is already refused above; the
4212
+ // truthy gate here only ever sees a non-empty directory path.
4180
4213
  if (args["evidence-dir"]) {
4181
4214
  const dir = args["evidence-dir"];
4182
- if (typeof dir !== "string" || dir.length === 0) {
4183
- return emitError("run: --evidence-dir must be a non-empty string.", null, pretty);
4215
+ if (typeof dir !== "string") {
4216
+ return emitError("run: --evidence-dir must be a string.", null, pretty);
4184
4217
  }
4185
4218
  if (!fs.existsSync(dir)) {
4186
4219
  return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
@@ -4951,7 +4984,11 @@ function walkAttestationDir(root, opts, candidates) {
4951
4984
  // Gate on the parsed kind so a renamed file cannot smuggle a replay
4952
4985
  // record into the listing.
4953
4986
  if (j && j.kind === "replay") continue;
4954
- if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
4987
+ // Filter on an explicitly-supplied playbook id. `!= null` (not a
4988
+ // truthiness check) so a future caller that threads an empty-string
4989
+ // id can't silently disable the filter and widen the match to every
4990
+ // playbook; the only legitimate "no filter" is `null`/`undefined`.
4991
+ if (opts.playbookId != null && j.playbook_id !== opts.playbookId) continue;
4955
4992
  if (opts.since && (j.captured_at || "") < opts.since) continue;
4956
4993
  if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
4957
4994
  candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
@@ -5154,12 +5191,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
5154
5191
  pretty,
5155
5192
  );
5156
5193
  }
5157
- const isoErr = validateIsoSince(cutoffRaw);
5194
+ const isoErr = validateIsoSince(cutoffRaw, "--all-older-than");
5158
5195
  if (isoErr) return emitError(`attest prune: ${isoErr}`, { verb: "attest prune" }, pretty);
5159
5196
  const cutoffMs = Date.parse(cutoffRaw);
5160
5197
  const dryRun = !!args["dry-run"];
5161
5198
 
5162
- const roots = [...new Set([resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")])];
5199
+ // Canonicalize before dedup. A plain Set over the two root strings only
5200
+ // collapses byte-identical paths, so when the default root and the cwd-
5201
+ // relative root resolve to the SAME directory via different strings (e.g. a
5202
+ // relative EXCEPTD_HOME like `.exceptd`, or the home-mkdir-fail fallback),
5203
+ // both survive and every session under that dir is scanned twice — inflating
5204
+ // scanned/kept/pruned_count and double-listing each session in the preview.
5205
+ // realpathSync resolves symlinks + makes absolute for an existing dir; for a
5206
+ // not-yet-created root it throws, so fall back to path.resolve (absolute +
5207
+ // normalized). Mirrors the realpath confinement used at delete time below.
5208
+ const canonicalRoot = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
5209
+ const roots = [];
5210
+ const seenRoots = new Set();
5211
+ for (const r of [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")]) {
5212
+ const c = canonicalRoot(r);
5213
+ if (seenRoots.has(c)) continue;
5214
+ seenRoots.add(c);
5215
+ roots.push(c);
5216
+ }
5163
5217
  const pruned = [];
5164
5218
  let kept = 0;
5165
5219
  let scanned = 0;
@@ -5197,16 +5251,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
5197
5251
  const ts = dateStr ? Date.parse(dateStr) : NaN;
5198
5252
  if (!Number.isFinite(ts)) { kept++; continue; }
5199
5253
  if (ts < cutoffMs) {
5200
- pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
5254
+ // Confinement: resolve and confirm sdir is a direct child of root
5255
+ // before it can be deleted, so a crafted session name can't escape the
5256
+ // root. Evaluate this in BOTH modes so the dry-run preview lists exactly
5257
+ // the set a real run will remove — a session the real run would refuse
5258
+ // (realpath escapes the root, or realpathSync throws) must not show up
5259
+ // as [would-delete]. realDir is reused for the rmSync below so the
5260
+ // delete and the gate operate on the same canonical path (no TOCTOU
5261
+ // between the check and the removal).
5262
+ let realDir = null;
5263
+ try {
5264
+ const realRoot = fs.realpathSync(root);
5265
+ const candidate = fs.realpathSync(sdir);
5266
+ if (path.dirname(candidate) === realRoot) realDir = candidate;
5267
+ } catch { /* unresolvable -> not deletable */ }
5268
+ if (realDir === null) { kept++; continue; }
5201
5269
  if (!dryRun) {
5202
- // Confinement: resolve and confirm sdir is a direct child of root
5203
- // before removing, so a crafted session name can't escape the root.
5204
- try {
5205
- const realRoot = fs.realpathSync(root);
5206
- const realDir = fs.realpathSync(sdir);
5207
- if (path.dirname(realDir) === realRoot) fs.rmSync(realDir, { recursive: true, force: true });
5208
- } catch { /* skip undeletable */ }
5270
+ // Real run: count the session as pruned only after the delete
5271
+ // succeeds, so pruned_count is a post-condition (sessions actually
5272
+ // removed from disk), never a candidate tally.
5273
+ try { fs.rmSync(realDir, { recursive: true, force: true }); }
5274
+ catch { kept++; continue; /* skip undeletable */ }
5209
5275
  }
5276
+ pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
5210
5277
  } else {
5211
5278
  kept++;
5212
5279
  }
@@ -5246,13 +5313,27 @@ function cmdReattest(runner, args, runOpts, pretty) {
5246
5313
  const sinceErr = validateIsoSince(args.since);
5247
5314
  if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
5248
5315
  }
5316
+ // Normalize --playbook (registered `multi:`, so a single value arrives as a
5317
+ // one-element array) and refuse an empty value. `--playbook ""` would
5318
+ // otherwise unwrap to "" and slip past walkAttestationDir's truthy filter
5319
+ // guard — silently widening --latest to the newest attestation across ALL
5320
+ // playbooks rather than the requested one. Refuse explicitly, the same way
5321
+ // --since refuses a malformed value above, so the operator sees the bad
5322
+ // input instead of an unintended cross-playbook match.
5323
+ let playbookFilter = null;
5324
+ if (args.playbook != null) {
5325
+ playbookFilter = Array.isArray(args.playbook) ? args.playbook[0] : args.playbook;
5326
+ if (typeof playbookFilter !== "string" || playbookFilter === "") {
5327
+ return emitError("reattest: --playbook was given an empty value. Pass a playbook id (e.g. --playbook kernel) or omit --playbook to match across all playbooks.", { verb: "reattest", flag: "playbook" }, pretty);
5328
+ }
5329
+ }
5249
5330
  // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
5250
5331
  // without requiring the operator to know the session-id.
5251
5332
  let sessionId = args._[0];
5252
5333
  let attFile = null;
5253
5334
  if (!sessionId && args.latest) {
5254
5335
  const found = findLatestAttestation({
5255
- playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
5336
+ playbookId: playbookFilter,
5256
5337
  since: args.since || null,
5257
5338
  });
5258
5339
  if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
@@ -5765,6 +5846,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
5765
5846
  // comparison. Without --against, replays current state against prior
5766
5847
  // session (= reattest). With --against, compares two sessions A vs B
5767
5848
  // by evidence_hash + artifact-level field diff.
5849
+ //
5850
+ // An empty `--against ""` / `--against=` parses to the empty string, which
5851
+ // is falsy — without this guard it would skip the explicit two-session
5852
+ // branch and silently fall through to the auto-prior path, comparing
5853
+ // against a DIFFERENT baseline than the operator named (a `--against
5854
+ // "$VAR"` that expanded to empty is the common footgun). REQUIRES_VALUE
5855
+ // only catches the value-less `--against` (parsed as `true`), not the
5856
+ // empty-string form. Refuse explicitly so the dropped target is signalled
5857
+ // rather than swapped under the operator.
5858
+ if (args.against === "") {
5859
+ return emitError(
5860
+ 'attest diff: --against was given an empty value; pass a session-id, or omit --against to diff against the most-recent prior.',
5861
+ { verb: "attest diff", flag: "against" },
5862
+ pretty
5863
+ );
5864
+ }
5768
5865
  if (args.against) {
5769
5866
  // Validate the --against id with the same gate as the primary sid, so a
5770
5867
  // traversal/garbage value (`../../etc/passwd`) gets the explicit "invalid
@@ -5870,14 +5967,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
5870
5967
  // counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
5871
5968
  // for flat submissions; the diff returned all zeros even when
5872
5969
  // artifacts were present in observations.
5873
- artifact_diff: diffArtifacts(
5874
- normalizedArtifacts(self.submission, runner, self.playbook_id),
5875
- normalizedArtifacts(other.submission, runner, other.playbook_id)
5876
- ),
5877
- signal_override_diff: diffSignalOverrides(
5878
- normalizedSignalOverrides(self.submission, runner, self.playbook_id),
5879
- normalizedSignalOverrides(other.submission, runner, other.playbook_id)
5880
- ),
5970
+ // The catalog stub stands in for an empty side ONLY when BOTH sides
5971
+ // are empty — substituting it for one empty side while the peer passes
5972
+ // through its real keys manufactures phantom drift (every catalog id
5973
+ // the populated side did not submit reads as added/changed).
5974
+ ...(() => {
5975
+ const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
5976
+ return {
5977
+ artifact_diff: diffArtifacts(
5978
+ normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
5979
+ normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty)
5980
+ ),
5981
+ signal_override_diff: diffSignalOverrides(
5982
+ normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
5983
+ normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty)
5984
+ ),
5985
+ };
5986
+ })(),
5881
5987
  }, pretty, renderAttestDiff);
5882
5988
  return;
5883
5989
  }
@@ -5954,14 +6060,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
5954
6060
  sidecar_verify: aSidecarVerify,
5955
6061
  a_sidecar_verify: aSidecarVerify,
5956
6062
  b_sidecar_verify: bSidecarVerify,
5957
- artifact_diff: diffArtifacts(
5958
- normalizedArtifacts(self.submission, runner, self.playbook_id),
5959
- normalizedArtifacts(other.submission, runner, other.playbook_id),
5960
- ),
5961
- signal_override_diff: diffSignalOverrides(
5962
- normalizedSignalOverrides(self.submission, runner, self.playbook_id),
5963
- normalizedSignalOverrides(other.submission, runner, other.playbook_id),
5964
- ),
6063
+ // Catalog stub stands in for an empty side only when BOTH sides are
6064
+ // empty (same peer-symmetric gate as the --against branch); real-vs-empty
6065
+ // diffs the populated side's keys against {}, not against the full catalog.
6066
+ ...(() => {
6067
+ const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
6068
+ return {
6069
+ artifact_diff: diffArtifacts(
6070
+ normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
6071
+ normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty),
6072
+ ),
6073
+ signal_override_diff: diffSignalOverrides(
6074
+ normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
6075
+ normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty),
6076
+ ),
6077
+ };
6078
+ })(),
5965
6079
  }, pretty, renderAttestDiff);
5966
6080
  return;
5967
6081
  }
@@ -6232,9 +6346,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
6232
6346
  run_opts: a.run_opts,
6233
6347
  artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
6234
6348
  .map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
6235
- signal_overrides: (a.submission && a.submission.signal_overrides) || {},
6349
+ // signal_overrides are operator-controllable: the contract canonicalizes
6350
+ // hit/miss/inconclusive verdicts but does NOT reject free-form values
6351
+ // (an unrecognized value surfaces a signal_override_unrecognized runtime
6352
+ // error yet is still stored verbatim in the submission), and the sibling
6353
+ // `<id>__fp_checks` keys carry arbitrary operator attestation maps. Under
6354
+ // a bundle labelled "redacted ... suitable for audit submission" the only
6355
+ // audit-meaningful content is the indicator verdict itself, so keep an
6356
+ // exact hit/miss/inconclusive value and replace everything else (free-form
6357
+ // strings, captured-data values, __fp_checks objects) with "[redacted]".
6358
+ // Apply the same keyname denylist as signals_redacted so an obviously-
6359
+ // sensitive key can't ride through under this field either. Matches the
6360
+ // signal-value-redaction contract asserted in tests/cli-coverage.js.
6361
+ signal_overrides: Object.fromEntries(Object.entries((a.submission && a.submission.signal_overrides) || {})
6362
+ .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
6363
+ .map(([k, v]) => [k, (v === "hit" || v === "miss" || v === "inconclusive") ? v : "[redacted]"])),
6364
+ // Redact the VALUES, not just drop obviously-sensitive keys: a submitted
6365
+ // signal value can hold operator data (e.g. a captured credential string),
6366
+ // and this field is labelled "redacted". The keyname denylist still drops
6367
+ // the obviously-sensitive keys entirely; every retained key keeps only a
6368
+ // "[redacted]" placeholder value, matching artifacts_redacted above.
6236
6369
  signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
6237
- .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))),
6370
+ .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
6371
+ .map(([k]) => [k, "[redacted]"])),
6238
6372
  precondition_checks: (a.submission && a.submission.precondition_checks) || {},
6239
6373
  }));
6240
6374
 
@@ -6256,7 +6390,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
6256
6390
  verb: "attest export",
6257
6391
  session_id: sessionId,
6258
6392
  exported_at: new Date().toISOString(),
6259
- redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides + precondition_checks + evidence_hash + signature preserved.",
6393
+ redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides reduced to hit/miss/inconclusive verdicts (free-form values redacted); precondition_checks + evidence_hash + signature preserved.",
6260
6394
  attestations: redacted,
6261
6395
  }, pretty);
6262
6396
  }
@@ -6296,9 +6430,25 @@ function _playbookSignalCatalog(runner, playbookId) {
6296
6430
  return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
6297
6431
  } catch { return null; }
6298
6432
  }
6299
- function normalizedArtifacts(submission, runner, playbookId) {
6433
+ // A submission carries real operator data for a diff when it supplied
6434
+ // artifacts, signal_overrides, OR observations. The empty case ({} or
6435
+ // {observations:{}}) is "no operator data was supplied." Diff symmetry hinges
6436
+ // on this predicate: the playbook catalog stub may only stand in for an empty
6437
+ // side when BOTH sides are empty (so the count reflects "N catalog ids,
6438
+ // uniformly empty on both sides"). Substituting the full catalog for one empty
6439
+ // side while the peer passes through its real keys manufactures phantom drift —
6440
+ // every catalog id the populated side did not submit shows up as "added"
6441
+ // (artifacts) or "changed" (signals). See callers for the bothEmpty gate.
6442
+ function submissionHasData(submission) {
6443
+ if (!submission || typeof submission !== "object") return false;
6444
+ const nonEmpty = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
6445
+ return nonEmpty(submission.artifacts)
6446
+ || nonEmpty(submission.signal_overrides)
6447
+ || nonEmpty(submission.observations);
6448
+ }
6449
+ function normalizedArtifacts(submission, runner, playbookId, applyEmptyFallback = true) {
6300
6450
  if (!submission || typeof submission !== "object") {
6301
- return _playbookArtifactCatalog(runner, playbookId) || {};
6451
+ return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
6302
6452
  }
6303
6453
  if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
6304
6454
  if (submission.observations && Object.keys(submission.observations).length > 0) {
@@ -6320,15 +6470,16 @@ function normalizedArtifacts(submission, runner, playbookId) {
6320
6470
  }
6321
6471
  return out;
6322
6472
  }
6323
- // v0.11.13 (#128): empty submission ({} or {observations:{}}). Identical
6324
- // hashes still mean "no operator data was supplied, same on both sides."
6325
- // Fall back to the playbook's look.artifacts catalog so total_compared
6326
- // reflects "N catalog artifacts, all uniformly empty on both sides."
6327
- return _playbookArtifactCatalog(runner, playbookId) || {};
6473
+ // Empty submission ({} or {observations:{}}). The catalog stub may only
6474
+ // stand in when the PEER side is also empty (applyEmptyFallback). When the
6475
+ // peer carried real artifacts, return an empty map so the populated side's
6476
+ // keys diff against nothing — yielding genuine added/removed instead of one
6477
+ // fabricated "added" per catalog id the operator never submitted.
6478
+ return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
6328
6479
  }
6329
- function normalizedSignalOverrides(submission, runner, playbookId) {
6480
+ function normalizedSignalOverrides(submission, runner, playbookId, applyEmptyFallback = true) {
6330
6481
  if (!submission || typeof submission !== "object") {
6331
- return _playbookSignalCatalog(runner, playbookId) || {};
6482
+ return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
6332
6483
  }
6333
6484
  if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
6334
6485
  if (submission.observations && Object.keys(submission.observations).length > 0) {
@@ -6347,7 +6498,10 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
6347
6498
  }
6348
6499
  return out;
6349
6500
  }
6350
- return _playbookSignalCatalog(runner, playbookId) || {};
6501
+ // Empty submission — same peer-symmetric gate as normalizedArtifacts: only
6502
+ // stand in the inconclusive catalog stub when the peer is also empty, so a
6503
+ // real-vs-empty signal diff reports only the genuinely-differing indicators.
6504
+ return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
6351
6505
  }
6352
6506
 
6353
6507
  /**
@@ -6398,14 +6552,14 @@ function diffArtifacts(a, b) {
6398
6552
  for (const id of allIds) {
6399
6553
  const av = a[id], bv = b[id];
6400
6554
  if (!av && bv) {
6401
- out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
6555
+ out.added.push({ id, captured: !!bv.captured, value_preview: artifactPreview(bv) });
6402
6556
  } else if (av && !bv) {
6403
- out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
6557
+ out.removed.push({ id, captured: !!av.captured, value_preview: artifactPreview(av) });
6404
6558
  } else if (av && bv && artifactsDiffer(av, bv)) {
6405
6559
  out.changed.push({
6406
6560
  id,
6407
6561
  a_captured: !!av.captured, b_captured: !!bv.captured,
6408
- a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
6562
+ a_value_preview: artifactPreview(av), b_value_preview: artifactPreview(bv),
6409
6563
  });
6410
6564
  } else if (av && bv) {
6411
6565
  // v0.11.8 (#102): both sides have the entry AND they're identical →
@@ -6438,6 +6592,23 @@ function previewValue(v) {
6438
6592
  return s.length > 80 ? s.slice(0, 80) + "…" : s;
6439
6593
  }
6440
6594
 
6595
+ // Preview the evidence an artifact carries for the diff output. `.value` is the
6596
+ // canonical carrier, but observations legitimately store their secret/path/match
6597
+ // under other keys (path, matched, reason, or a custom key). When `.value` is
6598
+ // absent, fall back to a preview of the remaining evidence-bearing keys (every
6599
+ // key except the bookkeeping `captured`/`captured_at` flags) so non-`value`
6600
+ // carriers still render instead of collapsing to a null preview — which hid the
6601
+ // actual differing content even when the per-field equality compare correctly
6602
+ // flagged the artifact as changed.
6603
+ function artifactPreview(art) {
6604
+ if (art === null || typeof art !== "object" || Array.isArray(art)) return previewValue(art);
6605
+ if (art.value !== undefined && art.value !== null) return previewValue(art.value);
6606
+ const { captured, captured_at, _captured_at, value, ...evidence } = art;
6607
+ const keys = Object.keys(evidence);
6608
+ if (keys.length === 0) return null;
6609
+ return previewValue(evidence);
6610
+ }
6611
+
6441
6612
  // ---------------------------------------------------------------------------
6442
6613
  // v0.11.0: cmdDiscover — context-aware playbook recommender.
6443
6614
  // Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
@@ -6448,6 +6619,11 @@ function cmdDiscover(runner, args, runOpts, pretty) {
6448
6619
  // process cwd. Pre-fix it was silently ignored — recommendations were
6449
6620
  // computed for the wrong directory with no signal. Validated like collect.
6450
6621
  let cwd = process.cwd();
6622
+ // An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
6623
+ // scan process.cwd() — the wrong directory — reported as a successful run.
6624
+ if (args.cwd === "") {
6625
+ return emitError(`discover: --cwd was given an empty value; pass an existing directory path`, { verb: "discover" }, pretty);
6626
+ }
6451
6627
  if (args.cwd) {
6452
6628
  const resolved = path.resolve(String(args.cwd));
6453
6629
  let stat;
@@ -7935,6 +8111,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
7935
8111
  if (!playbookId) {
7936
8112
  return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
7937
8113
  }
8114
+ // An explicit empty value (`--evidence ""`) is operator error, same as `run`.
8115
+ // The `--no-stream` path tests `args.evidence` for truthiness, so `""` fell
8116
+ // through to the stdin branch and — with empty/closed stdin — ran an empty
8117
+ // submission to ok:true at exit 0, masking that the intended evidence never
8118
+ // loaded. Reject it here so both stream and no-stream entry behave like `run`.
8119
+ if (args.evidence === "") {
8120
+ return emitError("ai-run: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence to read evidence from the stream", { verb: "ai-run" }, pretty);
8121
+ }
7938
8122
  if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
7939
8123
  let pb;
7940
8124
  try { pb = runner.loadPlaybook(playbookId); }
@@ -8684,6 +8868,27 @@ function cmdCi(runner, args, runOpts, pretty) {
8684
8868
  pretty,
8685
8869
  );
8686
8870
  }
8871
+ // An explicit empty value (`--evidence ""` / `--evidence=` / an unset shell
8872
+ // variable) is falsy, so the truthiness-gated evidence reads below skip
8873
+ // entirely, the bundle stays {}, every playbook runs with no evidence, and
8874
+ // the gate reports a clean PASS at exit 0 — a false-green that hides the fact
8875
+ // the operator's intended evidence never loaded. Mirror the run/collect/
8876
+ // discover empty-value guards: refuse the empty value loudly rather than
8877
+ // running a vacuous gate.
8878
+ if (args.evidence === "") {
8879
+ return emitError(
8880
+ "ci: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run",
8881
+ { verb: "ci", flag: "evidence" },
8882
+ pretty,
8883
+ );
8884
+ }
8885
+ if (args["evidence-dir"] === "") {
8886
+ return emitError(
8887
+ "ci: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir",
8888
+ { verb: "ci", flag: "evidence-dir" },
8889
+ pretty,
8890
+ );
8891
+ }
8687
8892
  const blockOnClock = !!args["block-on-jurisdiction-clock"];
8688
8893
 
8689
8894
  // v0.11.9 (#115): --required <playbook,playbook,...> takes precedence over
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-06-14T16:18:12.210Z",
3
+ "generated_at": "2026-06-20T16:35:33.578Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 64,
6
6
  "source_hashes": {
7
- "manifest.json": "2ddf8b26d75612209fb45014014c2729b0d6228e8a0eb3b6c2fb90b5997ce3f5",
7
+ "manifest.json": "6e8a5ac562c706b0142ad12b07a62982bb83d500870ca33a5d865bd17a16d23a",
8
8
  "README.md": "e7b854e7db9a364a1b368b5084b4f0c2a8282f0459ce39800ac1d1dabdc06074",
9
9
  "data/atlas-ttps.json": "5bc59e23d6c2defa54168de161a0825299b9cc4a49c6b26df2dae70b4f42eedf",
10
10
  "data/attack-techniques.json": "53c6f248760eecb11a0354f74ab467a5814e95075a686b9b3bf18c34e2f7435e",
11
- "data/cve-catalog.json": "eedd129c657e535d53df5d4d44fa54009b69cde85673cf7c9d426054eddc43d2",
11
+ "data/cve-catalog.json": "06ca53e69071dfe94867c10717f3ff2962c50341defcaf050f1a96236b5be51c",
12
12
  "data/cwe-catalog.json": "359263361fa52069e2856cc352d8f1c757d614ea840db6ef3ff5e696185ca220",
13
13
  "data/d3fend-catalog.json": "349d14b2777342d38e5f8b0149a9ebd7703ee59f67a9efee200d1e19313088ac",
14
14
  "data/dlp-controls.json": "d2406c482dddd30e49203879999dc4b3a7fd4d0494d6a61d86b91ee76415df19",
@@ -71,6 +71,25 @@
71
71
  },
72
72
  "skill_count": 51,
73
73
  "catalog_count": 11,
74
+ "outputs": [
75
+ "activity-feed.json",
76
+ "catalog-summaries.json",
77
+ "chains.json",
78
+ "currency.json",
79
+ "did-ladders.json",
80
+ "frequency.json",
81
+ "handoff-dag.json",
82
+ "jurisdiction-clocks.json",
83
+ "jurisdiction-map.json",
84
+ "recipes.json",
85
+ "section-offsets.json",
86
+ "stale-content.json",
87
+ "summary-cards.json",
88
+ "theater-fingerprints.json",
89
+ "token-budget.json",
90
+ "trigger-table.json",
91
+ "xref.json"
92
+ ],
74
93
  "index_stats": {
75
94
  "xref_entries": {
76
95
  "cwe_refs": 53,