@blamejs/exceptd-skills 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/exceptd.js CHANGED
@@ -101,7 +101,7 @@ const ORCHESTRATOR_PASSTHROUGH = new Set([
101
101
 
102
102
  // Seven-phase playbook verbs handled in-process (no subprocess dispatch).
103
103
  const PLAYBOOK_VERBS = new Set([
104
- "plan", "govern", "direct", "look", "run", "ingest", "reattest",
104
+ "plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations",
105
105
  ]);
106
106
 
107
107
  function readPkgVersion() {
@@ -149,8 +149,9 @@ Analyst:
149
149
 
150
150
  Playbook runner — seven-phase contract
151
151
  (govern → direct → look → detect → analyze → validate → close):
152
- plan [--playbook id]... List playbooks + directives (planning JSON).
153
- [--mode m] [--session-id id] [--pretty]
152
+ plan [--playbook id]... List playbooks + directives, grouped by scope.
153
+ [--scope system|code|service|cross-cutting|all]
154
+ [--flat] [--mode m] [--session-id id] [--pretty]
154
155
  govern <playbook> Phase 1: GRC context (jurisdictions, theater,
155
156
  framework gaps, skill_preload).
156
157
  [--directive id] [--mode m] [--air-gap]
@@ -160,8 +161,14 @@ Playbook runner — seven-phase contract
160
161
  look <playbook> Phase 3: artifact-collection spec the host AI
161
162
  should execute.
162
163
  [--directive id] [--air-gap]
163
- run <playbook> Phases 4-7: detect → analyze → validate → close
164
- from an agent submission JSON.
164
+ run [playbook] Phases 4-7: detect → analyze → validate → close.
165
+ Three invocation modes:
166
+ run <playbook> single playbook (explicit)
167
+ run --scope <type> run all playbooks of that scope
168
+ run --all run every playbook
169
+ run auto-detect from cwd:
170
+ .git/ → code
171
+ /proc + os-release → system
165
172
  [--directive id] [--evidence file|-]
166
173
  [--session-id id] [--session-key hex]
167
174
  [--force-stale] [--air-gap]
@@ -320,8 +327,15 @@ function firstDirectiveId(runner, playbookId) {
320
327
  }
321
328
 
322
329
  function dispatchPlaybook(cmd, argv) {
330
+ // Per-verb --help / -h before any positional-arg validation so users always
331
+ // get usage text instead of an error about missing arguments.
332
+ if (argv.includes("--help") || argv.includes("-h")) {
333
+ printPlaybookVerbHelp(cmd);
334
+ process.exit(0);
335
+ }
336
+
323
337
  const args = parseArgs(argv, {
324
- bool: ["pretty", "air-gap", "force-stale"],
338
+ bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives", "ci", "latest", "diff-from-latest"],
325
339
  multi: ["playbook"],
326
340
  });
327
341
  const pretty = !!args.pretty;
@@ -350,24 +364,190 @@ function dispatchPlaybook(cmd, argv) {
350
364
  case "run": return cmdRun(runner, args, runOpts, pretty);
351
365
  case "ingest": return cmdIngest(runner, args, runOpts, pretty);
352
366
  case "reattest": return cmdReattest(runner, args, runOpts, pretty);
367
+ case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
353
368
  }
354
369
  } catch (e) {
355
370
  emitError(e.message, { verb: cmd }, pretty);
356
371
  }
357
372
  }
358
373
 
374
+ function printPlaybookVerbHelp(verb) {
375
+ const cmds = {
376
+ plan: `plan — list playbooks + directives, grouped by scope.
377
+
378
+ Flags:
379
+ --playbook <id> ... Filter to one or more playbook IDs.
380
+ --scope <type> Filter by scope: system | code | service | cross-cutting | all
381
+ --flat Disable grouped-by-scope output; emit flat list.
382
+ --directives Include directive id + title + applies_to per playbook.
383
+ --session-id <id> Reuse a specific session ID for the planning output.
384
+ --mode <m> Investigation mode forwarded into govern.
385
+ --pretty Indented JSON output.`,
386
+ govern: `govern <playbook> — phase 1, load GRC context for a playbook.
387
+
388
+ Args / flags:
389
+ <playbook> Playbook ID. Required positional.
390
+ --directive <id> Specific directive (default: first one).
391
+ --mode <m> Investigation mode forwarded into govern policy.
392
+ --air-gap Honor _meta.air_gap_mode + air_gap_alternative paths.
393
+ --pretty Indented JSON output.
394
+
395
+ Output: jurisdiction_obligations, theater_fingerprints, framework_context, skill_preload.`,
396
+ direct: `direct <playbook> — phase 2, threat context + skill chain + token budget.
397
+
398
+ Args / flags:
399
+ <playbook> Required positional.
400
+ --directive <id> Specific directive (default: first one).
401
+ --pretty Indented JSON output.`,
402
+ look: `look <playbook> — phase 3, artifact-collection spec the host AI executes.
403
+
404
+ Args / flags:
405
+ <playbook> Required positional.
406
+ --directive <id> Specific directive (default: first one).
407
+ --air-gap Honor air_gap_alternative paths.
408
+ --pretty Indented JSON output.
409
+
410
+ Output includes a 'preconditions' array — the host AI MUST verify each
411
+ precondition with its own probes and declare results back in the submission as:
412
+ { "precondition_checks": { "<id>": true | false } }
413
+ The runner refuses the run if a precondition with on_fail=halt is unverified.`,
414
+ run: `run [playbook] — phases 4-7 (detect → analyze → validate → close).
415
+
416
+ Invocation modes:
417
+ run <playbook> Single playbook (explicit).
418
+ run --scope <type> Run all playbooks of that scope.
419
+ run --all Run every playbook.
420
+ run Auto-detect from cwd:
421
+ .git/ → code playbooks
422
+ /proc + os-release → system playbooks
423
+ Always includes cross-cutting playbooks.
424
+
425
+ Flags:
426
+ --directive <id> Specific directive (default: first one per playbook).
427
+ --evidence <file|-> Path to submission JSON or '-' for stdin.
428
+ Single-playbook shape:
429
+ { artifacts, signal_overrides, signals, precondition_checks }
430
+ Multi-playbook shape:
431
+ { "<playbook_id>": { artifacts, ... }, ... }
432
+ --vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
433
+ not_affected | resolved | false_positive (CycloneDX)
434
+ or not_affected | fixed (OpenVEX) drop out of
435
+ analyze.matched_cves. The disposition is preserved
436
+ under analyze.vex.dropped_cves.
437
+ --diff-from-latest Compare evidence_hash against the most recent prior
438
+ attestation for the same playbook in
439
+ .exceptd/attestations/. Emits status: unchanged | drifted.
440
+ --ci Machine-readable verdict for CI gates. Exits non-zero
441
+ (code 2) when phases.detect.classification === 'detected'
442
+ OR phases.analyze.rwep.adjusted >= rwep_threshold.escalate.
443
+ Logs PASS/FAIL reason to stderr.
444
+ --session-id <id> Reuse a specific session ID.
445
+ --session-key <hex> HMAC sign the evidence_package with this key.
446
+ --force-stale Override the threat_currency_score < 50 hard-block.
447
+ --air-gap Honor air_gap_alternative paths.
448
+ --pretty Indented JSON output.
449
+
450
+ Attestation is persisted to .exceptd/attestations/<session_id>/ on every
451
+ successful run (single: attestation.json; multi: <playbook_id>.json).`,
452
+ ingest: `ingest — alias for 'run' matching AGENTS.md terminology.
453
+
454
+ Flags:
455
+ --domain <id> Playbook ID (overrides submission.playbook_id).
456
+ --directive <id> Directive ID (overrides submission.directive_id).
457
+ --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
458
+ --pretty Indented JSON output.`,
459
+ reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
460
+
461
+ Args / flags:
462
+ <session-id> Looks under .exceptd/attestations/<id>/attestation.json.
463
+ --latest Find the most-recent attestation automatically.
464
+ --playbook <id> Restrict --latest to a specific playbook.
465
+ --since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
466
+ --pretty Indented JSON output.
467
+
468
+ Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
469
+ "list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
470
+
471
+ Args / flags:
472
+ --playbook <id> Filter to one playbook.
473
+ --pretty Indented JSON output.
474
+
475
+ Lists every attestation under .exceptd/attestations/<session_id>/, sorted
476
+ newest-first, with truncated evidence_hash + capture timestamp + file path.`,
477
+ };
478
+ process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
479
+ }
480
+
359
481
  function cmdPlan(runner, args, runOpts, pretty) {
360
- const playbookIds = args.playbook
482
+ let playbookIds = args.playbook
361
483
  ? (Array.isArray(args.playbook) ? args.playbook : [args.playbook])
362
484
  : null;
485
+ // --scope filters playbook list by _meta.scope.
486
+ if (!playbookIds && args.scope) {
487
+ playbookIds = filterPlaybooksByScope(runner, args.scope);
488
+ }
363
489
  const plan = runner.plan({
364
490
  playbookIds: playbookIds || undefined,
365
491
  mode: runOpts.mode,
366
492
  session_id: runOpts.session_id,
367
493
  });
494
+ // Default UX: group by scope unless --flat or a filter was applied.
495
+ if (!args.flat && !playbookIds) {
496
+ plan.grouped_by_scope = groupPlaybooksByScope(plan.playbooks);
497
+ plan.scope_summary = Object.fromEntries(
498
+ Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
499
+ );
500
+ }
501
+ // --directives expands each playbook entry with its directive id + title +
502
+ // applies_to so operators / AIs can pick a specific directive without
503
+ // grepping playbook source.
504
+ if (args.directives) {
505
+ for (const pb of plan.playbooks) {
506
+ const full = runner.loadPlaybook(pb.id);
507
+ pb.directives = full.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }));
508
+ }
509
+ }
368
510
  emit(plan, pretty);
369
511
  }
370
512
 
513
+ function filterPlaybooksByScope(runner, scope) {
514
+ const ids = runner.listPlaybooks();
515
+ return ids.filter(id => {
516
+ try {
517
+ const pb = runner.loadPlaybook(id);
518
+ return scope === "all" || pb._meta.scope === scope;
519
+ } catch { return false; }
520
+ });
521
+ }
522
+
523
+ function groupPlaybooksByScope(playbooks) {
524
+ const groups = {};
525
+ for (const pb of playbooks) {
526
+ const scope = pb.scope || pb._meta?.scope || "unscoped";
527
+ (groups[scope] = groups[scope] || []).push(pb.id);
528
+ }
529
+ return groups;
530
+ }
531
+
532
+ /**
533
+ * Auto-detect which scopes apply to the cwd. Returns an array of scope strings.
534
+ * - `code` when the cwd looks like a git repo
535
+ * - `system` when /proc + /etc/os-release exist (Linux host)
536
+ * - `service` always included as advisory — service investigations don't
537
+ * depend on cwd; the operator/AI decides whether to run them
538
+ *
539
+ * Returns at minimum `['cross-cutting']` so framework correlation can always
540
+ * run after other findings land.
541
+ */
542
+ function detectScopes() {
543
+ const detected = [];
544
+ if (fs.existsSync(path.join(process.cwd(), ".git"))) detected.push("code");
545
+ if (fs.existsSync("/proc") && fs.existsSync("/etc/os-release")) detected.push("system");
546
+ // service playbooks need explicit invocation — they have side effects
547
+ // (probing remote endpoints) so we don't auto-include them.
548
+ return detected.length ? detected : ["cross-cutting"];
549
+ }
550
+
371
551
  function cmdGovern(runner, args, runOpts, pretty) {
372
552
  const playbookId = args._[0];
373
553
  if (!playbookId) return emitError("govern: missing <playbookId> positional argument.", null, pretty);
@@ -396,8 +576,32 @@ function cmdLook(runner, args, runOpts, pretty) {
396
576
  }
397
577
 
398
578
  function cmdRun(runner, args, runOpts, pretty) {
399
- const playbookId = args._[0];
400
- if (!playbookId) return emitError("run: missing <playbookId> positional argument.", null, pretty);
579
+ const positional = args._[0];
580
+
581
+ // Multi-playbook dispatch path. Triggered by --all, --scope <type>, or by
582
+ // a bare `exceptd run` (no positional, no flags) which auto-detects scopes
583
+ // from the cwd.
584
+ if (!positional && (args.all || args.scope)) {
585
+ let ids;
586
+ if (args.all) {
587
+ ids = runner.listPlaybooks();
588
+ } else {
589
+ ids = filterPlaybooksByScope(runner, args.scope);
590
+ }
591
+ return cmdRunMulti(runner, ids, args, runOpts, pretty, { trigger: args.all ? "--all" : `--scope ${args.scope}` });
592
+ }
593
+ if (!positional && !args.all && !args.scope) {
594
+ const scopes = detectScopes();
595
+ const ids = scopes.flatMap(s => filterPlaybooksByScope(runner, s));
596
+ const unique = [...new Set(ids)];
597
+ if (unique.length === 0) {
598
+ return emitError("run: no playbook resolved. Pass <playbookId>, --scope <type>, or --all.", null, pretty);
599
+ }
600
+ return cmdRunMulti(runner, unique, args, runOpts, pretty, { trigger: "auto-detect", detected_scopes: scopes });
601
+ }
602
+
603
+ // Single-playbook path (existing behavior).
604
+ const playbookId = positional;
401
605
  const pb = runner.loadPlaybook(playbookId);
402
606
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
403
607
  if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
@@ -417,6 +621,19 @@ function cmdRun(runner, args, runOpts, pretty) {
417
621
  runOpts.precondition_checks = submission.precondition_checks;
418
622
  }
419
623
 
624
+ // --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
625
+ // CVE ID set through to analyze() so matched_cves drops them.
626
+ if (args.vex) {
627
+ try {
628
+ const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
629
+ const vexSet = runner.vexFilterFromDoc(vexDoc);
630
+ submission.signals = submission.signals || {};
631
+ submission.signals.vex_filter = [...vexSet];
632
+ } catch (e) {
633
+ return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
634
+ }
635
+ }
636
+
420
637
  const result = runner.run(playbookId, directiveId, submission, runOpts);
421
638
 
422
639
  // Persist attestation for reattest cycles when the run succeeded.
@@ -443,9 +660,146 @@ function cmdRun(runner, args, runOpts, pretty) {
443
660
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
444
661
  process.exit(1);
445
662
  }
663
+
664
+ // --diff-from-latest: compare evidence_hash against the most recent prior
665
+ // attestation for this playbook. Drift mode for cron baselines.
666
+ // We've already persisted the CURRENT attestation above, so the find must
667
+ // skip our session_id to get the actual prior one.
668
+ if (args["diff-from-latest"] && result && result.evidence_hash) {
669
+ const prior = findLatestAttestation({ playbookId, excludeSessionId: result.session_id });
670
+ if (prior) {
671
+ const priorHash = prior.parsed.evidence_hash;
672
+ result.diff_from_latest = {
673
+ prior_session_id: prior.parsed.session_id,
674
+ prior_captured_at: prior.parsed.captured_at,
675
+ prior_evidence_hash: priorHash,
676
+ new_evidence_hash: result.evidence_hash,
677
+ status: priorHash === result.evidence_hash ? "unchanged" : "drifted",
678
+ };
679
+ } else {
680
+ result.diff_from_latest = { status: "no_prior_attestation_for_playbook", playbook_id: playbookId };
681
+ }
682
+ }
683
+
684
+ // --ci: machine-readable verdict for CI gates.
685
+ //
686
+ // The detect phase classification is the host-specific signal — "is THIS
687
+ // environment exploitable for the catalogued CVEs". rwep.base is the
688
+ // worst-known catalog score for the domain, which is roughly constant
689
+ // regardless of the local environment; we don't fail CI on that alone or
690
+ // every CI run against a domain with KEV-listed catalog entries would
691
+ // perma-fail.
692
+ //
693
+ // Verdict:
694
+ // detected → FAIL (exit 2)
695
+ // inconclusive + rwep ≥ escalate
696
+ // → FAIL (the agent's evidence raised RWEP)
697
+ // not_detected → PASS (exit 0)
698
+ // inconclusive + rwep < escalate
699
+ // → PASS with WARN to stderr (visibility gap)
700
+ if (args.ci && result && result.phases) {
701
+ const classification = result.phases.detect && result.phases.detect.classification;
702
+ const rwep = result.phases.analyze && result.phases.analyze.rwep;
703
+ const threshold = rwep && rwep.threshold && rwep.threshold.escalate;
704
+ const adjusted = rwep && typeof rwep.adjusted === "number" ? rwep.adjusted : 0;
705
+ const escalate = typeof threshold === "number" && adjusted >= threshold;
706
+
707
+ emit(result, pretty);
708
+
709
+ if (classification === "detected") {
710
+ process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
711
+ process.exit(2);
712
+ }
713
+ if (classification === "inconclusive" && escalate) {
714
+ process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
715
+ process.exit(2);
716
+ }
717
+ if (classification === "inconclusive") {
718
+ process.stderr.write(`[exceptd run --ci] PASS+WARN: classification=inconclusive rwep=${adjusted} < threshold=${threshold} (visibility gap)\n`);
719
+ } else {
720
+ process.stderr.write(`[exceptd run --ci] PASS: classification=${classification} rwep=${adjusted}\n`);
721
+ }
722
+ return;
723
+ }
724
+
446
725
  emit(result, pretty);
447
726
  }
448
727
 
728
+ /**
729
+ * Multi-playbook run. Iterates `ids` (already filtered by scope or auto-detect),
730
+ * runs each through runner.run with a shared session_id, persists each
731
+ * attestation under .exceptd/attestations/<session_id>/<playbook_id>.json, and
732
+ * emits a single aggregate bundle. Refuses if no evidence is provided (the
733
+ * host AI MUST submit observations per playbook — the engine can't synthesize them).
734
+ *
735
+ * Evidence shape for multi-run: { <playbook_id>: { artifacts, signal_overrides, signals, precondition_checks } }
736
+ * Falls back to running every playbook with empty evidence (engine returns
737
+ * inconclusive findings + visibility gaps) when no --evidence is given.
738
+ */
739
+ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
740
+ const sessionId = runOpts.session_id || require("crypto").randomBytes(8).toString("hex");
741
+ runOpts.session_id = sessionId;
742
+
743
+ let bundle = {};
744
+ if (args.evidence) {
745
+ try { bundle = readEvidence(args.evidence); } catch (e) {
746
+ return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
747
+ }
748
+ }
749
+
750
+ const results = [];
751
+ for (const id of ids) {
752
+ const pb = runner.loadPlaybook(id);
753
+ const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
754
+ if (!directiveId) {
755
+ results.push({ playbook_id: id, ok: false, error: "no directives" });
756
+ continue;
757
+ }
758
+ const submission = bundle[id] || {};
759
+ const perRunOpts = { ...runOpts };
760
+ if (submission.precondition_checks) perRunOpts.precondition_checks = submission.precondition_checks;
761
+
762
+ const result = runner.run(id, directiveId, submission, perRunOpts);
763
+
764
+ // Persist per-playbook attestation under the shared session.
765
+ if (result && result.ok) {
766
+ try {
767
+ const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
768
+ fs.mkdirSync(dir, { recursive: true });
769
+ fs.writeFileSync(
770
+ path.join(dir, `${id}.json`),
771
+ JSON.stringify({
772
+ session_id: sessionId,
773
+ playbook_id: id,
774
+ directive_id: directiveId,
775
+ evidence_hash: result.evidence_hash,
776
+ submission,
777
+ run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
778
+ captured_at: new Date().toISOString(),
779
+ }, null, 2)
780
+ );
781
+ } catch { /* non-fatal */ }
782
+ }
783
+ results.push(result);
784
+ }
785
+
786
+ emit({
787
+ ok: results.every(r => r.ok !== false),
788
+ session_id: sessionId,
789
+ trigger: meta.trigger,
790
+ detected_scopes: meta.detected_scopes || null,
791
+ playbooks_run: ids,
792
+ summary: {
793
+ total: results.length,
794
+ succeeded: results.filter(r => r.ok !== false).length,
795
+ blocked: results.filter(r => r.ok === false).length,
796
+ detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
797
+ inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
798
+ },
799
+ results,
800
+ }, pretty);
801
+ }
802
+
449
803
  function cmdIngest(runner, args, runOpts, pretty) {
450
804
  // `ingest` matches the AGENTS.md ingest contract. The submission JSON may
451
805
  // carry playbook_id + directive_id; --domain/--directive flags override.
@@ -504,11 +858,52 @@ function cmdIngest(runner, args, runOpts, pretty) {
504
858
  emit(result, pretty);
505
859
  }
506
860
 
861
+ /**
862
+ * Find the latest attestation file under .exceptd/attestations/.
863
+ * Filters: optional playbook ID and optional "since" ISO timestamp.
864
+ * Returns { sessionId, playbookId, file, parsed } or null.
865
+ */
866
+ function findLatestAttestation(opts = {}) {
867
+ const root = path.join(process.cwd(), ".exceptd", "attestations");
868
+ if (!fs.existsSync(root)) return null;
869
+ const sessions = fs.readdirSync(root, { withFileTypes: true })
870
+ .filter(d => d.isDirectory())
871
+ .map(d => d.name);
872
+ const candidates = [];
873
+ for (const sid of sessions) {
874
+ const sdir = path.join(root, sid);
875
+ for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
876
+ try {
877
+ const p = path.join(sdir, f);
878
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
879
+ if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
880
+ if (opts.since && (j.captured_at || "") < opts.since) continue;
881
+ if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
882
+ candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
883
+ } catch { /* skip malformed */ }
884
+ }
885
+ }
886
+ candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
887
+ return candidates[0] || null;
888
+ }
889
+
507
890
  function cmdReattest(runner, args, runOpts, pretty) {
508
- const sessionId = args._[0];
509
- if (!sessionId) return emitError("reattest: missing <session-id> positional argument.", null, pretty);
891
+ // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
892
+ // without requiring the operator to know the session-id.
893
+ let sessionId = args._[0];
894
+ let attFile = null;
895
+ if (!sessionId && args.latest) {
896
+ const found = findLatestAttestation({
897
+ playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
898
+ since: args.since || null,
899
+ });
900
+ if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
901
+ sessionId = found.sessionId;
902
+ attFile = found.file;
903
+ }
904
+ if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
510
905
  const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
511
- const attFile = path.join(dir, "attestation.json");
906
+ if (!attFile) attFile = path.join(dir, "attestation.json");
512
907
  if (!fs.existsSync(attFile)) {
513
908
  return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
514
909
  }
@@ -580,6 +975,44 @@ function cmdReattest(runner, args, runOpts, pretty) {
580
975
  }, pretty);
581
976
  }
582
977
 
978
+ function cmdListAttestations(runner, args, runOpts, pretty) {
979
+ const root = path.join(process.cwd(), ".exceptd", "attestations");
980
+ if (!fs.existsSync(root)) {
981
+ return emit({ ok: true, attestations: [], note: `No attestations directory at ${path.relative(process.cwd(), root)}` }, pretty);
982
+ }
983
+ const sessions = fs.readdirSync(root, { withFileTypes: true })
984
+ .filter(d => d.isDirectory())
985
+ .map(d => d.name);
986
+
987
+ const entries = [];
988
+ for (const sid of sessions) {
989
+ const sdir = path.join(root, sid);
990
+ const files = fs.readdirSync(sdir).filter(f => f.endsWith(".json"));
991
+ for (const f of files) {
992
+ try {
993
+ const j = JSON.parse(fs.readFileSync(path.join(sdir, f), "utf8"));
994
+ // Apply --playbook filter if supplied.
995
+ if (args.playbook && j.playbook_id !== args.playbook) continue;
996
+ entries.push({
997
+ session_id: sid,
998
+ playbook_id: j.playbook_id,
999
+ directive_id: j.directive_id,
1000
+ evidence_hash: j.evidence_hash ? j.evidence_hash.slice(0, 16) + "..." : null,
1001
+ captured_at: j.captured_at || null,
1002
+ file: path.relative(process.cwd(), path.join(sdir, f)),
1003
+ });
1004
+ } catch { /* skip malformed */ }
1005
+ }
1006
+ }
1007
+ entries.sort((a, b) => (b.captured_at || "").localeCompare(a.captured_at || ""));
1008
+ emit({
1009
+ ok: true,
1010
+ attestations: entries,
1011
+ count: entries.length,
1012
+ filter: { playbook: args.playbook || null },
1013
+ }, pretty);
1014
+ }
1015
+
583
1016
  if (require.main === module) main();
584
1017
 
585
1018
  module.exports = { COMMANDS, PKG_ROOT, PLAYBOOK_VERBS };
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-12T05:54:24.968Z",
3
+ "generated_at": "2026-05-12T13:50:32.319Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "d10eff5e3267bca05be76ef3146f0ccd995a4d5a2dd5c958430b251432dfadff",
7
+ "manifest.json": "3614ad87835688365283c95a8b7af1d66f14928c07efef750c206ba2f13aab33",
8
8
  "data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
9
9
  "data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
10
10
  "data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",