@blamejs/exceptd-skills 0.18.6 → 0.18.8

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 (63) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/bin/exceptd.js +364 -119
  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 +29 -5
  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 +443 -50
  22. package/lib/prefetch.js +22 -2
  23. package/lib/refresh-external.js +32 -1
  24. package/lib/refresh-network.js +235 -26
  25. package/lib/schemas/cve-catalog.schema.json +5 -0
  26. package/lib/scoring.js +141 -21
  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 +30 -4
  33. package/lib/validate-indexes.js +135 -29
  34. package/lib/validate-playbooks.js +19 -7
  35. package/lib/validate-vendor.js +69 -8
  36. package/lib/verify.js +23 -6
  37. package/manifest.json +53 -53
  38. package/orchestrator/dispatcher.js +14 -3
  39. package/orchestrator/index.js +100 -20
  40. package/orchestrator/scanner.js +8 -0
  41. package/package.json +1 -1
  42. package/sbom.cdx.json +130 -130
  43. package/scripts/audit-cross-skill.js +1 -1
  44. package/scripts/bootstrap.js +1 -0
  45. package/scripts/build-indexes.js +84 -11
  46. package/scripts/check-agents-md-collectors.js +41 -13
  47. package/scripts/check-changelog-extract.js +4 -4
  48. package/scripts/check-codebase-patterns.js +19 -5
  49. package/scripts/check-manifest-snapshot.js +74 -30
  50. package/scripts/check-sbom-currency.js +25 -5
  51. package/scripts/check-test-count.js +26 -7
  52. package/scripts/check-test-coverage.js +44 -4
  53. package/scripts/check-version-tags.js +27 -8
  54. package/scripts/predeploy.js +1 -1
  55. package/scripts/refresh-manifest-snapshot.js +14 -4
  56. package/scripts/refresh-reverse-refs.js +7 -1
  57. package/scripts/refresh-sbom.js +1 -1
  58. package/scripts/release.js +3 -3
  59. package/scripts/run-e2e-scenarios.js +18 -8
  60. package/scripts/validate-vendor-online.js +28 -2
  61. package/scripts/verify-shipped-tarball.js +65 -6
  62. package/sources/validators/cve-validator.js +17 -1
  63. package/vendor/blamejs/_PROVENANCE.json +4 -2
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) {
@@ -1462,9 +1469,11 @@ function dispatchPlaybook(cmd, argv) {
1462
1469
  }
1463
1470
  runOpts.session_key = args["session-key"];
1464
1471
  }
1465
- if (args.mode) {
1472
+ if (args.mode !== undefined) {
1466
1473
  // Bug #32: validate --mode against the accepted set. Previously
1467
- // `--mode garbage` was silently accepted.
1474
+ // `--mode garbage` was silently accepted. Gate on `!== undefined` (not
1475
+ // truthiness) so `--mode ""` is also rejected by the set check below rather
1476
+ // than silently slipping past as a falsy value.
1468
1477
  const VALID_MODES = ["self_service", "authorized_pentest", "ir_response", "ctf", "research", "compliance_audit"];
1469
1478
  if (!VALID_MODES.includes(args.mode)) {
1470
1479
  // v0.13.2: did-you-mean on flag-value typos (Levenshtein ≤ 2).
@@ -2497,6 +2506,11 @@ async function cmdCollect(runner, args, runOpts, pretty) {
2497
2506
  // --cwd <path> overrides process.cwd(). Validated as an existing
2498
2507
  // directory; non-existent / non-directory cwd is operator error.
2499
2508
  let cwd = process.cwd();
2509
+ // An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
2510
+ // scan process.cwd() — the wrong directory — reported as a successful run.
2511
+ if (args.cwd === "") {
2512
+ return emitError(`collect: --cwd was given an empty value; pass an existing directory path`, { verb: "collect", playbook_id: playbookId }, pretty);
2513
+ }
2500
2514
  if (args.cwd) {
2501
2515
  const resolved = path.resolve(String(args.cwd));
2502
2516
  let stat;
@@ -2830,7 +2844,10 @@ function cmdLint(runner, args, runOpts, pretty) {
2830
2844
 
2831
2845
  function cmdBrief(runner, args, runOpts, pretty) {
2832
2846
  const playbookId = args._[0];
2833
- const onlyPhase = args.phase || null;
2847
+ // Preserve an explicit empty string (don't coerce "" -> null) so the
2848
+ // accepted-set check below rejects `--phase ""` instead of silently treating
2849
+ // it as "no filter" and emitting the full brief. Only an OMITTED flag is null.
2850
+ const onlyPhase = args.phase === undefined ? null : args.phase;
2834
2851
 
2835
2852
  // v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
2836
2853
  // Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
@@ -2953,6 +2970,11 @@ function cmdVerifyAttestation(runner, args, runOpts, pretty) {
2953
2970
  }
2954
2971
 
2955
2972
  function cmdPlan(runner, args, runOpts, pretty) {
2973
+ // Reject an empty --playbook value rather than letting the truthy gate below
2974
+ // coerce it to null and silently plan across ALL playbooks (wrong scope).
2975
+ if (args.playbook === "" || (Array.isArray(args.playbook) && args.playbook.some(p => p === ""))) {
2976
+ return emitError("plan: --playbook was given an empty value; pass a playbook id, or omit --playbook to plan across all.", { verb: "plan", flag: "playbook" }, pretty);
2977
+ }
2956
2978
  let playbookIds = args.playbook
2957
2979
  ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
2958
2980
  : null;
@@ -3358,6 +3380,12 @@ function cmdRun(runner, args, runOpts, pretty) {
3358
3380
  // first, then falls back to a strict isTTY===false check only on Windows
3359
3381
  // (where fstat on a pipe is unreliable). MSYS-bash on win32 reports
3360
3382
  // isTTY === false for genuine piped input, so that path still works.
3383
+ // An explicit empty value (`--evidence ""`) is operator error: it would
3384
+ // otherwise be falsy and silently produce a no-evidence "not_detected" run at
3385
+ // exit 0, masking the fact that the intended evidence never loaded.
3386
+ if (args.evidence === "") {
3387
+ 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);
3388
+ }
3361
3389
  const autoStdin = !args.evidence && hasReadableStdin();
3362
3390
  if (autoStdin) {
3363
3391
  args.evidence = "-";
@@ -4168,6 +4196,20 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4168
4196
  runOpts.session_id = sessionId;
4169
4197
 
4170
4198
  let bundle = {};
4199
+ // An explicit empty value (--evidence "" / --evidence= / an unset shell
4200
+ // variable) is falsy, so the truthiness-gated reads below would skip
4201
+ // entirely, the bundle would stay {}, every playbook would run with no
4202
+ // evidence, and the contract would report a clean not_detected at exit 0 —
4203
+ // a false-clean that hides the fact the operator's intended evidence never
4204
+ // loaded. Mirror the single-playbook run / ci empty-value guards: refuse
4205
+ // the empty value loudly rather than running a vacuous contract. Presence,
4206
+ // not truthiness, is the test.
4207
+ if (args.evidence === "") {
4208
+ 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);
4209
+ }
4210
+ if (args["evidence-dir"] === "") {
4211
+ return emitError("run: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir", { verb: "run", flag: "evidence-dir" }, pretty);
4212
+ }
4171
4213
  if (args.evidence) {
4172
4214
  try { bundle = readEvidence(args.evidence); } catch (e) {
4173
4215
  return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
@@ -4176,11 +4218,12 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4176
4218
  // --evidence-dir <dir>: each <playbook-id>.json under the directory is read
4177
4219
  // as that playbook's submission. Lets operators wire up one cron job that
4178
4220
  // collects per-playbook evidence into a directory, then runs the whole
4179
- // contract in one pass.
4221
+ // contract in one pass. The empty-string form is already refused above; the
4222
+ // truthy gate here only ever sees a non-empty directory path.
4180
4223
  if (args["evidence-dir"]) {
4181
4224
  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);
4225
+ if (typeof dir !== "string") {
4226
+ return emitError("run: --evidence-dir must be a string.", null, pretty);
4184
4227
  }
4185
4228
  if (!fs.existsSync(dir)) {
4186
4229
  return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
@@ -4210,55 +4253,83 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
4210
4253
  return emitError(`run: --evidence-dir entry ${f} resolves outside the directory; refusing.`, null, pretty);
4211
4254
  }
4212
4255
  // The path.resolve check above only catches `..` traversal in the
4213
- // joined path; fs.readFileSync(entryPath) still follows symlinks, so
4214
- // a `<pb-id>.json -> /etc/shadow` symlink inside the dir would happily
4215
- // slurp the target. lstat is symlink-aware (it does NOT follow);
4216
- // refuse anything that's not a regular file. Defense in depth on top
4217
- // of the readdir filter — a junction (Windows) or bind-mount can
4218
- // shape-shift in between filter and read.
4219
- let lst;
4220
- try { lst = fs.lstatSync(entryPath); }
4221
- catch (e) {
4222
- return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
4223
- }
4224
- if (lst.isSymbolicLink()) {
4225
- return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
4226
- }
4227
- if (!lst.isFile()) {
4228
- return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing.`, { entry: f }, pretty);
4229
- }
4230
- // Windows directory junctions are reparse-point dirs that
4231
- // `lstat().isSymbolicLink()` returns FALSE for (Node treats them as
4232
- // ordinary directories), bypassing the symlink refusal above. Use
4233
- // realpathSync to resolve the entry and confirm it still lives under
4234
- // the resolved evidence-dir — the realpath approach is portable
4235
- // (catches POSIX symlinks too, defense in depth) and works regardless
4236
- // of whether the OS exposes reparse-point bits.
4237
- let realEntry;
4238
- try { realEntry = fs.realpathSync(entryPath); }
4239
- catch (e) {
4240
- return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
4241
- }
4242
- if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
4243
- return emitError(
4244
- `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
4245
- { entry: f, resolved_to: realEntry },
4246
- pretty
4247
- );
4248
- }
4249
- // Hardlink defense in depth: no clean cross-platform refusal exists —
4250
- // hardlinks are indistinguishable from regular files at the inode
4251
- // level. Surface a stderr warning when nlink > 1 so the operator is
4252
- // aware a second name may point at the same file. Not a refusal —
4253
- // legitimate use cases (atomic rename, package-manager dedup) produce
4254
- // nlink > 1 without malicious intent.
4255
- if (lst.nlink > 1) {
4256
- process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${lst.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
4256
+ // joined path; reading the path would still follow symlinks, so a
4257
+ // `<pb-id>.json -> /etc/shadow` symlink inside the dir would slurp the
4258
+ // target. Rather than lstat/realpath the PATH and then re-open it
4259
+ // (a check-then-use TOCTOU window), open a single O_NOFOLLOW descriptor
4260
+ // FIRST and make every subsequent decision about that exact descriptor.
4261
+ // O_NOFOLLOW refuses a symlinked leaf at open (ELOOP) on POSIX; on
4262
+ // Windows it is a no-op, so the fstat type check + realpath gate below
4263
+ // carry the junction/symlink defense. Opening before any path stat means
4264
+ // the bytes read come from the inode we validated, not a path that could
4265
+ // be re-pointed between check and read.
4266
+ let efd;
4267
+ try {
4268
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW || 0;
4269
+ efd = fs.openSync(entryPath, fs.constants.O_RDONLY | O_NOFOLLOW);
4270
+ } catch (e) {
4271
+ const why = e.code === "ELOOP"
4272
+ ? "symbolic link refused (symlinks bypass the directory-confinement check)"
4273
+ : e.message;
4274
+ return emitError(`run: --evidence-dir entry ${f}: open failed: ${why}`, { entry: f }, pretty);
4257
4275
  }
4258
4276
  try {
4259
- bundle[pbId] = JSON.parse(fs.readFileSync(entryPath, "utf8"));
4277
+ const st = fs.fstatSync(efd);
4278
+ if (!st.isFile()) {
4279
+ return emitError(`run: --evidence-dir entry ${f} is not a regular file; refusing (symlink / junction / dir / fifo bypass the directory-confinement check).`, { entry: f }, pretty);
4280
+ }
4281
+ // Hardlink defense in depth: no clean cross-platform refusal exists —
4282
+ // hardlinks are indistinguishable from regular files at the inode
4283
+ // level. Surface a stderr warning when nlink > 1 so the operator is
4284
+ // aware a second name may point at the same file. Not a refusal —
4285
+ // legitimate use cases (atomic rename, package-manager dedup) produce
4286
+ // nlink > 1 without malicious intent.
4287
+ if (st.nlink > 1) {
4288
+ process.stderr.write(`[exceptd run --evidence-dir] WARNING: ${f} has nlink=${st.nlink}; a hardlink to this file exists elsewhere on the filesystem. Hardlinks cannot be refused cross-platform — confirm the file content is what you expect.\n`);
4289
+ }
4290
+ // Read the bytes from `efd` FIRST — the descriptor was opened
4291
+ // O_NOFOLLOW and fstat-confirmed a regular file, so this reads the exact
4292
+ // inode we validated. Reading before the realpath gate (rather than
4293
+ // checking the path then reading) means there is no check-then-use
4294
+ // window at all; the containment gate below decides whether to USE the
4295
+ // bytes, and discards them otherwise.
4296
+ const raw = fs.readFileSync(efd, "utf8");
4297
+ // Symlink refusal. O_NOFOLLOW already rejects a symlinked leaf at open on
4298
+ // POSIX (ELOOP), but it is a no-op on Windows, where the open follows the
4299
+ // link. Detect and refuse a symlink explicitly via lstat — regardless of
4300
+ // where it points — so a symlinked entry is never accepted. This runs
4301
+ // AFTER the descriptor read (the bytes are dropped on refusal), so there
4302
+ // is no path-check-before-read TOCTOU window.
4303
+ let lst;
4304
+ try { lst = fs.lstatSync(entryPath); }
4305
+ catch (e) {
4306
+ return emitError(`run: --evidence-dir entry ${f}: lstat failed: ${e.message}`, null, pretty);
4307
+ }
4308
+ if (lst.isSymbolicLink()) {
4309
+ return emitError(`run: --evidence-dir entry ${f} is a symbolic link; refusing (symlinks bypass the directory-confinement check).`, { entry: f }, pretty);
4310
+ }
4311
+ // Windows directory junctions are reparse-point dirs that
4312
+ // lstat().isSymbolicLink() returns FALSE for, and O_NOFOLLOW is a
4313
+ // no-op there; realpath resolves the entry and confirms it still lives
4314
+ // under the resolved evidence-dir. A target that escapes the dir is
4315
+ // refused and the already-read bytes are dropped unused.
4316
+ let realEntry;
4317
+ try { realEntry = fs.realpathSync(entryPath); }
4318
+ catch (e) {
4319
+ return emitError(`run: --evidence-dir entry ${f}: realpath failed: ${e.message}`, null, pretty);
4320
+ }
4321
+ if (realEntry !== entryPath && !realEntry.startsWith(resolvedDir + path.sep)) {
4322
+ return emitError(
4323
+ `run: --evidence-dir entry ${f} resolves outside the directory (junction / reparse-point / symlink target). Refusing.`,
4324
+ { entry: f, resolved_to: realEntry },
4325
+ pretty
4326
+ );
4327
+ }
4328
+ bundle[pbId] = JSON.parse(raw);
4260
4329
  } catch (e) {
4261
- return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
4330
+ return emitError(`run: failed to read --evidence-dir entry ${f}: ${e.message}`, null, pretty);
4331
+ } finally {
4332
+ try { fs.closeSync(efd); } catch { /* already closed / invalid fd */ }
4262
4333
  }
4263
4334
  }
4264
4335
  }
@@ -4951,7 +5022,11 @@ function walkAttestationDir(root, opts, candidates) {
4951
5022
  // Gate on the parsed kind so a renamed file cannot smuggle a replay
4952
5023
  // record into the listing.
4953
5024
  if (j && j.kind === "replay") continue;
4954
- if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
5025
+ // Filter on an explicitly-supplied playbook id. `!= null` (not a
5026
+ // truthiness check) so a future caller that threads an empty-string
5027
+ // id can't silently disable the filter and widen the match to every
5028
+ // playbook; the only legitimate "no filter" is `null`/`undefined`.
5029
+ if (opts.playbookId != null && j.playbook_id !== opts.playbookId) continue;
4955
5030
  if (opts.since && (j.captured_at || "") < opts.since) continue;
4956
5031
  if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
4957
5032
  candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
@@ -5154,12 +5229,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
5154
5229
  pretty,
5155
5230
  );
5156
5231
  }
5157
- const isoErr = validateIsoSince(cutoffRaw);
5232
+ const isoErr = validateIsoSince(cutoffRaw, "--all-older-than");
5158
5233
  if (isoErr) return emitError(`attest prune: ${isoErr}`, { verb: "attest prune" }, pretty);
5159
5234
  const cutoffMs = Date.parse(cutoffRaw);
5160
5235
  const dryRun = !!args["dry-run"];
5161
5236
 
5162
- const roots = [...new Set([resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")])];
5237
+ // Canonicalize before dedup. A plain Set over the two root strings only
5238
+ // collapses byte-identical paths, so when the default root and the cwd-
5239
+ // relative root resolve to the SAME directory via different strings (e.g. a
5240
+ // relative EXCEPTD_HOME like `.exceptd`, or the home-mkdir-fail fallback),
5241
+ // both survive and every session under that dir is scanned twice — inflating
5242
+ // scanned/kept/pruned_count and double-listing each session in the preview.
5243
+ // realpathSync resolves symlinks + makes absolute for an existing dir; for a
5244
+ // not-yet-created root it throws, so fall back to path.resolve (absolute +
5245
+ // normalized). Mirrors the realpath confinement used at delete time below.
5246
+ const canonicalRoot = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
5247
+ const roots = [];
5248
+ const seenRoots = new Set();
5249
+ for (const r of [resolveAttestationRoot(runOpts), path.join(process.cwd(), ".exceptd", "attestations")]) {
5250
+ const c = canonicalRoot(r);
5251
+ if (seenRoots.has(c)) continue;
5252
+ seenRoots.add(c);
5253
+ roots.push(c);
5254
+ }
5163
5255
  const pruned = [];
5164
5256
  let kept = 0;
5165
5257
  let scanned = 0;
@@ -5197,16 +5289,29 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
5197
5289
  const ts = dateStr ? Date.parse(dateStr) : NaN;
5198
5290
  if (!Number.isFinite(ts)) { kept++; continue; }
5199
5291
  if (ts < cutoffMs) {
5200
- pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
5292
+ // Confinement: resolve and confirm sdir is a direct child of root
5293
+ // before it can be deleted, so a crafted session name can't escape the
5294
+ // root. Evaluate this in BOTH modes so the dry-run preview lists exactly
5295
+ // the set a real run will remove — a session the real run would refuse
5296
+ // (realpath escapes the root, or realpathSync throws) must not show up
5297
+ // as [would-delete]. realDir is reused for the rmSync below so the
5298
+ // delete and the gate operate on the same canonical path (no TOCTOU
5299
+ // between the check and the removal).
5300
+ let realDir = null;
5301
+ try {
5302
+ const realRoot = fs.realpathSync(root);
5303
+ const candidate = fs.realpathSync(sdir);
5304
+ if (path.dirname(candidate) === realRoot) realDir = candidate;
5305
+ } catch { /* unresolvable -> not deletable */ }
5306
+ if (realDir === null) { kept++; continue; }
5201
5307
  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 */ }
5308
+ // Real run: count the session as pruned only after the delete
5309
+ // succeeds, so pruned_count is a post-condition (sessions actually
5310
+ // removed from disk), never a candidate tally.
5311
+ try { fs.rmSync(realDir, { recursive: true, force: true }); }
5312
+ catch { kept++; continue; /* skip undeletable */ }
5209
5313
  }
5314
+ pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
5210
5315
  } else {
5211
5316
  kept++;
5212
5317
  }
@@ -5246,13 +5351,27 @@ function cmdReattest(runner, args, runOpts, pretty) {
5246
5351
  const sinceErr = validateIsoSince(args.since);
5247
5352
  if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
5248
5353
  }
5354
+ // Normalize --playbook (registered `multi:`, so a single value arrives as a
5355
+ // one-element array) and refuse an empty value. `--playbook ""` would
5356
+ // otherwise unwrap to "" and slip past walkAttestationDir's truthy filter
5357
+ // guard — silently widening --latest to the newest attestation across ALL
5358
+ // playbooks rather than the requested one. Refuse explicitly, the same way
5359
+ // --since refuses a malformed value above, so the operator sees the bad
5360
+ // input instead of an unintended cross-playbook match.
5361
+ let playbookFilter = null;
5362
+ if (args.playbook != null) {
5363
+ playbookFilter = Array.isArray(args.playbook) ? args.playbook[0] : args.playbook;
5364
+ if (typeof playbookFilter !== "string" || playbookFilter === "") {
5365
+ 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);
5366
+ }
5367
+ }
5249
5368
  // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
5250
5369
  // without requiring the operator to know the session-id.
5251
5370
  let sessionId = args._[0];
5252
5371
  let attFile = null;
5253
5372
  if (!sessionId && args.latest) {
5254
5373
  const found = findLatestAttestation({
5255
- playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
5374
+ playbookId: playbookFilter,
5256
5375
  since: args.since || null,
5257
5376
  });
5258
5377
  if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
@@ -5560,8 +5679,8 @@ function cmdReattest(runner, args, runOpts, pretty) {
5560
5679
  lines.push(`attest diff: ${obj.session_id} (${obj.playbook_id})`);
5561
5680
  const icon = obj.status === "unchanged" ? "[ok]" : "[i DRIFTED]";
5562
5681
  lines.push(`\n${icon} status=${obj.status}`);
5563
- lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at})`);
5564
- lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at})`);
5682
+ lines.push(` prior: ${obj.prior_evidence_hash} (${obj.prior_captured_at || '(no detail)'})`);
5683
+ lines.push(` replay: ${obj.replay_evidence_hash} (${obj.replayed_at || '(no detail)'})`);
5565
5684
  if (obj.replay_classification) {
5566
5685
  lines.push(` replay classification: ${obj.replay_classification} RWEP=${obj.replay_rwep_adjusted ?? 0}`);
5567
5686
  }
@@ -5667,13 +5786,13 @@ function renderAttestDiff(obj) {
5667
5786
  lines.push(` artifact diff: ${ad.added?.length ?? 0} added, ${ad.removed?.length ?? 0} removed, ${ad.changed?.length ?? 0} changed, ${ad.unchanged_count ?? 0} unchanged (of ${ad.total_compared ?? 0})`);
5668
5787
  lines.push(` signal diff: ${sd.changed?.length ?? 0} changed, ${sd.unchanged_count ?? 0} unchanged (of ${sd.total_compared ?? 0})`);
5669
5788
  if (obj.sidecar_verify) {
5670
- const sv = obj.sidecar_verify;
5671
- let sidecarClass = "verified";
5672
- if (!sv.signed && sv.reason && sv.reason.includes("explicitly unsigned")) sidecarClass = "explicitly-unsigned";
5673
- else if (!sv.signed && sv.reason && sv.reason.includes("no .sig sidecar")) sidecarClass = "no-sidecar";
5674
- else if (sv.signed && !sv.verified) sidecarClass = "tamper-detected";
5675
- else if (!sv.signed) sidecarClass = "no-public-key";
5676
- lines.push(` sidecar verify: ${sidecarClass}`);
5789
+ // Use the canonical classifier, which checks tamper_class BEFORE the reason
5790
+ // strings. The previous inline logic matched reason.includes("explicitly
5791
+ // unsigned") first, so an unsigned-SUBSTITUTION attack (whose reason also
5792
+ // contains "explicitly unsigned" but carries tamper_class:
5793
+ // 'unsigned-substitution') was mislabeled as the benign 'explicitly-unsigned'
5794
+ // — hiding the substitution signal in the human diff output.
5795
+ lines.push(` sidecar verify: ${classifySidecarVerify(obj.sidecar_verify)}`);
5677
5796
  }
5678
5797
  return lines.join("\n");
5679
5798
  }
@@ -5765,6 +5884,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
5765
5884
  // comparison. Without --against, replays current state against prior
5766
5885
  // session (= reattest). With --against, compares two sessions A vs B
5767
5886
  // by evidence_hash + artifact-level field diff.
5887
+ //
5888
+ // An empty `--against ""` / `--against=` parses to the empty string, which
5889
+ // is falsy — without this guard it would skip the explicit two-session
5890
+ // branch and silently fall through to the auto-prior path, comparing
5891
+ // against a DIFFERENT baseline than the operator named (a `--against
5892
+ // "$VAR"` that expanded to empty is the common footgun). REQUIRES_VALUE
5893
+ // only catches the value-less `--against` (parsed as `true`), not the
5894
+ // empty-string form. Refuse explicitly so the dropped target is signalled
5895
+ // rather than swapped under the operator.
5896
+ if (args.against === "") {
5897
+ return emitError(
5898
+ 'attest diff: --against was given an empty value; pass a session-id, or omit --against to diff against the most-recent prior.',
5899
+ { verb: "attest diff", flag: "against" },
5900
+ pretty
5901
+ );
5902
+ }
5768
5903
  if (args.against) {
5769
5904
  // Validate the --against id with the same gate as the primary sid, so a
5770
5905
  // traversal/garbage value (`../../etc/passwd`) gets the explicit "invalid
@@ -5870,14 +6005,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
5870
6005
  // counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
5871
6006
  // for flat submissions; the diff returned all zeros even when
5872
6007
  // 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
- ),
6008
+ // The catalog stub stands in for an empty side ONLY when BOTH sides
6009
+ // are empty — substituting it for one empty side while the peer passes
6010
+ // through its real keys manufactures phantom drift (every catalog id
6011
+ // the populated side did not submit reads as added/changed).
6012
+ ...(() => {
6013
+ const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
6014
+ return {
6015
+ artifact_diff: diffArtifacts(
6016
+ normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
6017
+ normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty)
6018
+ ),
6019
+ signal_override_diff: diffSignalOverrides(
6020
+ normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
6021
+ normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty)
6022
+ ),
6023
+ };
6024
+ })(),
5881
6025
  }, pretty, renderAttestDiff);
5882
6026
  return;
5883
6027
  }
@@ -5954,14 +6098,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
5954
6098
  sidecar_verify: aSidecarVerify,
5955
6099
  a_sidecar_verify: aSidecarVerify,
5956
6100
  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
- ),
6101
+ // Catalog stub stands in for an empty side only when BOTH sides are
6102
+ // empty (same peer-symmetric gate as the --against branch); real-vs-empty
6103
+ // diffs the populated side's keys against {}, not against the full catalog.
6104
+ ...(() => {
6105
+ const bothEmpty = !submissionHasData(self.submission) && !submissionHasData(other.submission);
6106
+ return {
6107
+ artifact_diff: diffArtifacts(
6108
+ normalizedArtifacts(self.submission, runner, self.playbook_id, bothEmpty),
6109
+ normalizedArtifacts(other.submission, runner, other.playbook_id, bothEmpty),
6110
+ ),
6111
+ signal_override_diff: diffSignalOverrides(
6112
+ normalizedSignalOverrides(self.submission, runner, self.playbook_id, bothEmpty),
6113
+ normalizedSignalOverrides(other.submission, runner, other.playbook_id, bothEmpty),
6114
+ ),
6115
+ };
6116
+ })(),
5965
6117
  }, pretty, renderAttestDiff);
5966
6118
  return;
5967
6119
  }
@@ -6232,9 +6384,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
6232
6384
  run_opts: a.run_opts,
6233
6385
  artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
6234
6386
  .map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
6235
- signal_overrides: (a.submission && a.submission.signal_overrides) || {},
6387
+ // signal_overrides are operator-controllable: the contract canonicalizes
6388
+ // hit/miss/inconclusive verdicts but does NOT reject free-form values
6389
+ // (an unrecognized value surfaces a signal_override_unrecognized runtime
6390
+ // error yet is still stored verbatim in the submission), and the sibling
6391
+ // `<id>__fp_checks` keys carry arbitrary operator attestation maps. Under
6392
+ // a bundle labelled "redacted ... suitable for audit submission" the only
6393
+ // audit-meaningful content is the indicator verdict itself, so keep an
6394
+ // exact hit/miss/inconclusive value and replace everything else (free-form
6395
+ // strings, captured-data values, __fp_checks objects) with "[redacted]".
6396
+ // Apply the same keyname denylist as signals_redacted so an obviously-
6397
+ // sensitive key can't ride through under this field either. Matches the
6398
+ // signal-value-redaction contract asserted in tests/cli-coverage.js.
6399
+ signal_overrides: Object.fromEntries(Object.entries((a.submission && a.submission.signal_overrides) || {})
6400
+ .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
6401
+ .map(([k, v]) => [k, (v === "hit" || v === "miss" || v === "inconclusive") ? v : "[redacted]"])),
6402
+ // Redact the VALUES, not just drop obviously-sensitive keys: a submitted
6403
+ // signal value can hold operator data (e.g. a captured credential string),
6404
+ // and this field is labelled "redacted". The keyname denylist still drops
6405
+ // the obviously-sensitive keys entirely; every retained key keeps only a
6406
+ // "[redacted]" placeholder value, matching artifacts_redacted above.
6236
6407
  signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
6237
- .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))),
6408
+ .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))
6409
+ .map(([k]) => [k, "[redacted]"])),
6238
6410
  precondition_checks: (a.submission && a.submission.precondition_checks) || {},
6239
6411
  }));
6240
6412
 
@@ -6256,7 +6428,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
6256
6428
  verb: "attest export",
6257
6429
  session_id: sessionId,
6258
6430
  exported_at: new Date().toISOString(),
6259
- redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides + precondition_checks + evidence_hash + signature preserved.",
6431
+ 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
6432
  attestations: redacted,
6261
6433
  }, pretty);
6262
6434
  }
@@ -6296,9 +6468,25 @@ function _playbookSignalCatalog(runner, playbookId) {
6296
6468
  return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
6297
6469
  } catch { return null; }
6298
6470
  }
6299
- function normalizedArtifacts(submission, runner, playbookId) {
6471
+ // A submission carries real operator data for a diff when it supplied
6472
+ // artifacts, signal_overrides, OR observations. The empty case ({} or
6473
+ // {observations:{}}) is "no operator data was supplied." Diff symmetry hinges
6474
+ // on this predicate: the playbook catalog stub may only stand in for an empty
6475
+ // side when BOTH sides are empty (so the count reflects "N catalog ids,
6476
+ // uniformly empty on both sides"). Substituting the full catalog for one empty
6477
+ // side while the peer passes through its real keys manufactures phantom drift —
6478
+ // every catalog id the populated side did not submit shows up as "added"
6479
+ // (artifacts) or "changed" (signals). See callers for the bothEmpty gate.
6480
+ function submissionHasData(submission) {
6481
+ if (!submission || typeof submission !== "object") return false;
6482
+ const nonEmpty = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
6483
+ return nonEmpty(submission.artifacts)
6484
+ || nonEmpty(submission.signal_overrides)
6485
+ || nonEmpty(submission.observations);
6486
+ }
6487
+ function normalizedArtifacts(submission, runner, playbookId, applyEmptyFallback = true) {
6300
6488
  if (!submission || typeof submission !== "object") {
6301
- return _playbookArtifactCatalog(runner, playbookId) || {};
6489
+ return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
6302
6490
  }
6303
6491
  if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
6304
6492
  if (submission.observations && Object.keys(submission.observations).length > 0) {
@@ -6320,15 +6508,16 @@ function normalizedArtifacts(submission, runner, playbookId) {
6320
6508
  }
6321
6509
  return out;
6322
6510
  }
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) || {};
6511
+ // Empty submission ({} or {observations:{}}). The catalog stub may only
6512
+ // stand in when the PEER side is also empty (applyEmptyFallback). When the
6513
+ // peer carried real artifacts, return an empty map so the populated side's
6514
+ // keys diff against nothing — yielding genuine added/removed instead of one
6515
+ // fabricated "added" per catalog id the operator never submitted.
6516
+ return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
6328
6517
  }
6329
- function normalizedSignalOverrides(submission, runner, playbookId) {
6518
+ function normalizedSignalOverrides(submission, runner, playbookId, applyEmptyFallback = true) {
6330
6519
  if (!submission || typeof submission !== "object") {
6331
- return _playbookSignalCatalog(runner, playbookId) || {};
6520
+ return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
6332
6521
  }
6333
6522
  if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
6334
6523
  if (submission.observations && Object.keys(submission.observations).length > 0) {
@@ -6347,7 +6536,10 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
6347
6536
  }
6348
6537
  return out;
6349
6538
  }
6350
- return _playbookSignalCatalog(runner, playbookId) || {};
6539
+ // Empty submission — same peer-symmetric gate as normalizedArtifacts: only
6540
+ // stand in the inconclusive catalog stub when the peer is also empty, so a
6541
+ // real-vs-empty signal diff reports only the genuinely-differing indicators.
6542
+ return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
6351
6543
  }
6352
6544
 
6353
6545
  /**
@@ -6398,14 +6590,14 @@ function diffArtifacts(a, b) {
6398
6590
  for (const id of allIds) {
6399
6591
  const av = a[id], bv = b[id];
6400
6592
  if (!av && bv) {
6401
- out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
6593
+ out.added.push({ id, captured: !!bv.captured, value_preview: artifactPreview(bv) });
6402
6594
  } else if (av && !bv) {
6403
- out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
6595
+ out.removed.push({ id, captured: !!av.captured, value_preview: artifactPreview(av) });
6404
6596
  } else if (av && bv && artifactsDiffer(av, bv)) {
6405
6597
  out.changed.push({
6406
6598
  id,
6407
6599
  a_captured: !!av.captured, b_captured: !!bv.captured,
6408
- a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
6600
+ a_value_preview: artifactPreview(av), b_value_preview: artifactPreview(bv),
6409
6601
  });
6410
6602
  } else if (av && bv) {
6411
6603
  // v0.11.8 (#102): both sides have the entry AND they're identical →
@@ -6438,6 +6630,23 @@ function previewValue(v) {
6438
6630
  return s.length > 80 ? s.slice(0, 80) + "…" : s;
6439
6631
  }
6440
6632
 
6633
+ // Preview the evidence an artifact carries for the diff output. `.value` is the
6634
+ // canonical carrier, but observations legitimately store their secret/path/match
6635
+ // under other keys (path, matched, reason, or a custom key). When `.value` is
6636
+ // absent, fall back to a preview of the remaining evidence-bearing keys (every
6637
+ // key except the bookkeeping `captured`/`captured_at` flags) so non-`value`
6638
+ // carriers still render instead of collapsing to a null preview — which hid the
6639
+ // actual differing content even when the per-field equality compare correctly
6640
+ // flagged the artifact as changed.
6641
+ function artifactPreview(art) {
6642
+ if (art === null || typeof art !== "object" || Array.isArray(art)) return previewValue(art);
6643
+ if (art.value !== undefined && art.value !== null) return previewValue(art.value);
6644
+ const { captured, captured_at, _captured_at, value, ...evidence } = art;
6645
+ const keys = Object.keys(evidence);
6646
+ if (keys.length === 0) return null;
6647
+ return previewValue(evidence);
6648
+ }
6649
+
6441
6650
  // ---------------------------------------------------------------------------
6442
6651
  // v0.11.0: cmdDiscover — context-aware playbook recommender.
6443
6652
  // Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
@@ -6448,6 +6657,11 @@ function cmdDiscover(runner, args, runOpts, pretty) {
6448
6657
  // process cwd. Pre-fix it was silently ignored — recommendations were
6449
6658
  // computed for the wrong directory with no signal. Validated like collect.
6450
6659
  let cwd = process.cwd();
6660
+ // An explicit empty value (`--cwd ""`) would otherwise be falsy and silently
6661
+ // scan process.cwd() — the wrong directory — reported as a successful run.
6662
+ if (args.cwd === "") {
6663
+ return emitError(`discover: --cwd was given an empty value; pass an existing directory path`, { verb: "discover" }, pretty);
6664
+ }
6451
6665
  if (args.cwd) {
6452
6666
  const resolved = path.resolve(String(args.cwd));
6453
6667
  let stat;
@@ -6509,11 +6723,13 @@ function cmdDiscover(runner, args, runOpts, pretty) {
6509
6723
  let hostDistro = null;
6510
6724
  if (hostPlatform === "linux") {
6511
6725
  try {
6512
- const res = spawnSync("cat", ["/etc/os-release"], { encoding: "utf8" });
6513
- if (res.status === 0 && res.stdout) {
6514
- const idMatch = res.stdout.match(/^ID=(.+)$/m);
6515
- const verMatch = res.stdout.match(/^VERSION_ID=(.+)$/m);
6516
- const prettyMatch = res.stdout.match(/^PRETTY_NAME=(.+)$/m);
6726
+ // Read the file directly instead of spawning `cat` (a process to do what
6727
+ // fs does, and a static-analysis "unnecessary use of cat" flag).
6728
+ const osRelease = fs.readFileSync("/etc/os-release", "utf8");
6729
+ if (osRelease) {
6730
+ const idMatch = osRelease.match(/^ID=(.+)$/m);
6731
+ const verMatch = osRelease.match(/^VERSION_ID=(.+)$/m);
6732
+ const prettyMatch = osRelease.match(/^PRETTY_NAME=(.+)$/m);
6517
6733
  hostDistro = {
6518
6734
  id: idMatch ? idMatch[1].replace(/^"|"$/g, "") : null,
6519
6735
  version_id: verMatch ? verMatch[1].replace(/^"|"$/g, "") : null,
@@ -7935,6 +8151,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
7935
8151
  if (!playbookId) {
7936
8152
  return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
7937
8153
  }
8154
+ // An explicit empty value (`--evidence ""`) is operator error, same as `run`.
8155
+ // The `--no-stream` path tests `args.evidence` for truthiness, so `""` fell
8156
+ // through to the stdin branch and — with empty/closed stdin — ran an empty
8157
+ // submission to ok:true at exit 0, masking that the intended evidence never
8158
+ // loaded. Reject it here so both stream and no-stream entry behave like `run`.
8159
+ if (args.evidence === "") {
8160
+ 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);
8161
+ }
7938
8162
  if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
7939
8163
  let pb;
7940
8164
  try { pb = runner.loadPlaybook(playbookId); }
@@ -8684,6 +8908,27 @@ function cmdCi(runner, args, runOpts, pretty) {
8684
8908
  pretty,
8685
8909
  );
8686
8910
  }
8911
+ // An explicit empty value (`--evidence ""` / `--evidence=` / an unset shell
8912
+ // variable) is falsy, so the truthiness-gated evidence reads below skip
8913
+ // entirely, the bundle stays {}, every playbook runs with no evidence, and
8914
+ // the gate reports a clean PASS at exit 0 — a false-green that hides the fact
8915
+ // the operator's intended evidence never loaded. Mirror the run/collect/
8916
+ // discover empty-value guards: refuse the empty value loudly rather than
8917
+ // running a vacuous gate.
8918
+ if (args.evidence === "") {
8919
+ return emitError(
8920
+ "ci: --evidence was given an empty value; pass a file path, '-' for stdin, or omit --evidence for a no-evidence run",
8921
+ { verb: "ci", flag: "evidence" },
8922
+ pretty,
8923
+ );
8924
+ }
8925
+ if (args["evidence-dir"] === "") {
8926
+ return emitError(
8927
+ "ci: --evidence-dir was given an empty value; pass an existing directory, or omit --evidence-dir",
8928
+ { verb: "ci", flag: "evidence-dir" },
8929
+ pretty,
8930
+ );
8931
+ }
8687
8932
  const blockOnClock = !!args["block-on-jurisdiction-clock"];
8688
8933
 
8689
8934
  // v0.11.9 (#115): --required <playbook,playbook,...> takes precedence over