@blamejs/exceptd-skills 0.18.5 → 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 +24 -0
  2. package/bin/exceptd.js +277 -60
  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,29 @@
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
+
23
+ ## 0.18.6 — 2026-06-14
24
+
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.
26
+
3
27
  ## 0.18.5 — 2026-06-14
4
28
 
5
29
  The manifest's per-skill cross-reference arrays — data dependencies, framework gaps, and ATT&CK / ATLAS / D3FEND / RFC / CWE references — now include every reference a skill declares in its own frontmatter. Previously a reference present in a skill's frontmatter but missing from the manifest cache could silently drop out of the reverse-reference surfaces (for instance, a skill not appearing under a D3FEND technique it actually maps). A predeploy gate now holds the manifest cache to cover frontmatter, so the reference graph cannot drift again.
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;
@@ -5174,27 +5228,52 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
5174
5228
  // attestation. A session with no parseable captured_at is left alone
5175
5229
  // (never delete something we can't date).
5176
5230
  let captured = null;
5231
+ let replayFallback = null;
5177
5232
  try {
5178
5233
  for (const f of fs.readdirSync(sdir)) {
5179
5234
  if (!f.endsWith(".json") || f.endsWith(".sig")) continue;
5180
5235
  let j; try { j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8")); } catch { continue; }
5181
- if (!j || j.kind === "replay") continue;
5236
+ if (!j) continue;
5237
+ if (j.kind === "replay") {
5238
+ // A session holding only replay records (its attestation was
5239
+ // removed) would otherwise be undateable and never GC'd, so the
5240
+ // store grows without bound. Fall back to the newest replay
5241
+ // timestamp so prune can still age such a session out.
5242
+ if (typeof j.replayed_at === "string" && (!replayFallback || j.replayed_at > replayFallback)) replayFallback = j.replayed_at;
5243
+ continue;
5244
+ }
5182
5245
  if (typeof j.captured_at === "string" && (!captured || j.captured_at > captured)) captured = j.captured_at;
5183
5246
  }
5184
5247
  } catch { continue; }
5185
- const ts = captured ? Date.parse(captured) : NaN;
5248
+ // Prefer the signed attestation's captured_at; fall back to the newest
5249
+ // replay timestamp for replay-only sessions.
5250
+ const dateStr = captured || replayFallback;
5251
+ const ts = dateStr ? Date.parse(dateStr) : NaN;
5186
5252
  if (!Number.isFinite(ts)) { kept++; continue; }
5187
5253
  if (ts < cutoffMs) {
5188
- pruned.push({ session_id: sid, captured_at: captured, 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; }
5189
5269
  if (!dryRun) {
5190
- // Confinement: resolve and confirm sdir is a direct child of root
5191
- // before removing, so a crafted session name can't escape the root.
5192
- try {
5193
- const realRoot = fs.realpathSync(root);
5194
- const realDir = fs.realpathSync(sdir);
5195
- if (path.dirname(realDir) === realRoot) fs.rmSync(realDir, { recursive: true, force: true });
5196
- } 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 */ }
5197
5275
  }
5276
+ pruned.push({ session_id: sid, captured_at: captured, replayed_at: captured ? undefined : replayFallback, dir: sdir });
5198
5277
  } else {
5199
5278
  kept++;
5200
5279
  }
@@ -5209,13 +5288,13 @@ function cmdPruneAttestations(runner, args, runOpts, pretty) {
5209
5288
  scanned,
5210
5289
  pruned_count: pruned.length,
5211
5290
  kept,
5212
- pruned: pruned.map(p => ({ session_id: p.session_id, captured_at: p.captured_at })),
5291
+ pruned: pruned.map(p => ({ session_id: p.session_id, captured_at: p.captured_at, replayed_at: p.replayed_at })),
5213
5292
  roots_searched: roots,
5214
5293
  }, pretty, (obj) => {
5215
5294
  const lines = [];
5216
5295
  lines.push(`attest prune${obj.dry_run ? " (DRY-RUN)" : ""}: cutoff ${obj.cutoff}`);
5217
5296
  lines.push(` scanned ${obj.scanned} session(s) | ${obj.dry_run ? "would prune" : "pruned"} ${obj.pruned_count} | kept ${obj.kept}`);
5218
- for (const p of obj.pruned.slice(0, 20)) lines.push(` ${obj.dry_run ? "[would-delete]" : "[deleted]"} ${p.session_id} (${(p.captured_at || "").slice(0, 19)})`);
5297
+ for (const p of obj.pruned.slice(0, 20)) lines.push(` ${obj.dry_run ? "[would-delete]" : "[deleted]"} ${p.session_id} (${((p.captured_at || p.replayed_at) || "").slice(0, 19)})`);
5219
5298
  if (obj.pruned_count > 20) lines.push(` … ${obj.pruned_count - 20} more`);
5220
5299
  if (obj.dry_run && obj.pruned_count > 0) lines.push(` → re-run without --dry-run to delete.`);
5221
5300
  return lines.join("\n");
@@ -5234,13 +5313,27 @@ function cmdReattest(runner, args, runOpts, pretty) {
5234
5313
  const sinceErr = validateIsoSince(args.since);
5235
5314
  if (sinceErr) return emitError(`reattest: ${sinceErr}`, null, pretty);
5236
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
+ }
5237
5330
  // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
5238
5331
  // without requiring the operator to know the session-id.
5239
5332
  let sessionId = args._[0];
5240
5333
  let attFile = null;
5241
5334
  if (!sessionId && args.latest) {
5242
5335
  const found = findLatestAttestation({
5243
- playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
5336
+ playbookId: playbookFilter,
5244
5337
  since: args.since || null,
5245
5338
  });
5246
5339
  if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
@@ -5753,6 +5846,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
5753
5846
  // comparison. Without --against, replays current state against prior
5754
5847
  // session (= reattest). With --against, compares two sessions A vs B
5755
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
+ }
5756
5865
  if (args.against) {
5757
5866
  // Validate the --against id with the same gate as the primary sid, so a
5758
5867
  // traversal/garbage value (`../../etc/passwd`) gets the explicit "invalid
@@ -5858,14 +5967,23 @@ function cmdAttest(runner, args, runOpts, pretty) {
5858
5967
  // counts. Pre-0.11.8 (self.submission||{}).artifacts was undefined
5859
5968
  // for flat submissions; the diff returned all zeros even when
5860
5969
  // artifacts were present in observations.
5861
- artifact_diff: diffArtifacts(
5862
- normalizedArtifacts(self.submission, runner, self.playbook_id),
5863
- normalizedArtifacts(other.submission, runner, other.playbook_id)
5864
- ),
5865
- signal_override_diff: diffSignalOverrides(
5866
- normalizedSignalOverrides(self.submission, runner, self.playbook_id),
5867
- normalizedSignalOverrides(other.submission, runner, other.playbook_id)
5868
- ),
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
+ })(),
5869
5987
  }, pretty, renderAttestDiff);
5870
5988
  return;
5871
5989
  }
@@ -5942,14 +6060,22 @@ function cmdAttest(runner, args, runOpts, pretty) {
5942
6060
  sidecar_verify: aSidecarVerify,
5943
6061
  a_sidecar_verify: aSidecarVerify,
5944
6062
  b_sidecar_verify: bSidecarVerify,
5945
- artifact_diff: diffArtifacts(
5946
- normalizedArtifacts(self.submission, runner, self.playbook_id),
5947
- normalizedArtifacts(other.submission, runner, other.playbook_id),
5948
- ),
5949
- signal_override_diff: diffSignalOverrides(
5950
- normalizedSignalOverrides(self.submission, runner, self.playbook_id),
5951
- normalizedSignalOverrides(other.submission, runner, other.playbook_id),
5952
- ),
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
+ })(),
5953
6079
  }, pretty, renderAttestDiff);
5954
6080
  return;
5955
6081
  }
@@ -6220,9 +6346,29 @@ function cmdAttest(runner, args, runOpts, pretty) {
6220
6346
  run_opts: a.run_opts,
6221
6347
  artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
6222
6348
  .map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
6223
- 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.
6224
6369
  signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
6225
- .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]"])),
6226
6372
  precondition_checks: (a.submission && a.submission.precondition_checks) || {},
6227
6373
  }));
6228
6374
 
@@ -6244,7 +6390,7 @@ function cmdAttest(runner, args, runOpts, pretty) {
6244
6390
  verb: "attest export",
6245
6391
  session_id: sessionId,
6246
6392
  exported_at: new Date().toISOString(),
6247
- 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.",
6248
6394
  attestations: redacted,
6249
6395
  }, pretty);
6250
6396
  }
@@ -6284,9 +6430,25 @@ function _playbookSignalCatalog(runner, playbookId) {
6284
6430
  return Object.fromEntries(inds.map(i => [i.id, 'inconclusive']));
6285
6431
  } catch { return null; }
6286
6432
  }
6287
- 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) {
6288
6450
  if (!submission || typeof submission !== "object") {
6289
- return _playbookArtifactCatalog(runner, playbookId) || {};
6451
+ return applyEmptyFallback ? (_playbookArtifactCatalog(runner, playbookId) || {}) : {};
6290
6452
  }
6291
6453
  if (submission.artifacts && Object.keys(submission.artifacts).length > 0) return submission.artifacts;
6292
6454
  if (submission.observations && Object.keys(submission.observations).length > 0) {
@@ -6308,15 +6470,16 @@ function normalizedArtifacts(submission, runner, playbookId) {
6308
6470
  }
6309
6471
  return out;
6310
6472
  }
6311
- // v0.11.13 (#128): empty submission ({} or {observations:{}}). Identical
6312
- // hashes still mean "no operator data was supplied, same on both sides."
6313
- // Fall back to the playbook's look.artifacts catalog so total_compared
6314
- // reflects "N catalog artifacts, all uniformly empty on both sides."
6315
- 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) || {}) : {};
6316
6479
  }
6317
- function normalizedSignalOverrides(submission, runner, playbookId) {
6480
+ function normalizedSignalOverrides(submission, runner, playbookId, applyEmptyFallback = true) {
6318
6481
  if (!submission || typeof submission !== "object") {
6319
- return _playbookSignalCatalog(runner, playbookId) || {};
6482
+ return applyEmptyFallback ? (_playbookSignalCatalog(runner, playbookId) || {}) : {};
6320
6483
  }
6321
6484
  if (submission.signal_overrides && Object.keys(submission.signal_overrides).length > 0) return submission.signal_overrides;
6322
6485
  if (submission.observations && Object.keys(submission.observations).length > 0) {
@@ -6335,7 +6498,10 @@ function normalizedSignalOverrides(submission, runner, playbookId) {
6335
6498
  }
6336
6499
  return out;
6337
6500
  }
6338
- 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) || {}) : {};
6339
6505
  }
6340
6506
 
6341
6507
  /**
@@ -6386,14 +6552,14 @@ function diffArtifacts(a, b) {
6386
6552
  for (const id of allIds) {
6387
6553
  const av = a[id], bv = b[id];
6388
6554
  if (!av && bv) {
6389
- out.added.push({ id, captured: !!bv.captured, value_preview: previewValue(bv.value) });
6555
+ out.added.push({ id, captured: !!bv.captured, value_preview: artifactPreview(bv) });
6390
6556
  } else if (av && !bv) {
6391
- out.removed.push({ id, captured: !!av.captured, value_preview: previewValue(av.value) });
6557
+ out.removed.push({ id, captured: !!av.captured, value_preview: artifactPreview(av) });
6392
6558
  } else if (av && bv && artifactsDiffer(av, bv)) {
6393
6559
  out.changed.push({
6394
6560
  id,
6395
6561
  a_captured: !!av.captured, b_captured: !!bv.captured,
6396
- a_value_preview: previewValue(av.value), b_value_preview: previewValue(bv.value),
6562
+ a_value_preview: artifactPreview(av), b_value_preview: artifactPreview(bv),
6397
6563
  });
6398
6564
  } else if (av && bv) {
6399
6565
  // v0.11.8 (#102): both sides have the entry AND they're identical →
@@ -6426,6 +6592,23 @@ function previewValue(v) {
6426
6592
  return s.length > 80 ? s.slice(0, 80) + "…" : s;
6427
6593
  }
6428
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
+
6429
6612
  // ---------------------------------------------------------------------------
6430
6613
  // v0.11.0: cmdDiscover — context-aware playbook recommender.
6431
6614
  // Collapses scan + dispatch + recommend into one verb. Sniffs the cwd, reads
@@ -6436,6 +6619,11 @@ function cmdDiscover(runner, args, runOpts, pretty) {
6436
6619
  // process cwd. Pre-fix it was silently ignored — recommendations were
6437
6620
  // computed for the wrong directory with no signal. Validated like collect.
6438
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
+ }
6439
6627
  if (args.cwd) {
6440
6628
  const resolved = path.resolve(String(args.cwd));
6441
6629
  let stat;
@@ -7923,6 +8111,14 @@ function cmdAiRun(runner, args, runOpts, pretty) {
7923
8111
  if (!playbookId) {
7924
8112
  return emitError("ai-run: missing <playbook> positional argument.", null, pretty);
7925
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
+ }
7926
8122
  if (refuseInvalidPlaybookId("ai-run", playbookId, pretty)) return;
7927
8123
  let pb;
7928
8124
  try { pb = runner.loadPlaybook(playbookId); }
@@ -8672,6 +8868,27 @@ function cmdCi(runner, args, runOpts, pretty) {
8672
8868
  pretty,
8673
8869
  );
8674
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
+ }
8675
8892
  const blockOnClock = !!args["block-on-jurisdiction-clock"];
8676
8893
 
8677
8894
  // v0.11.9 (#115): --required <playbook,playbook,...> takes precedence over