@blamejs/exceptd-skills 0.10.1 → 0.10.3

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", "list-attestations",
104
+ "plan", "govern", "direct", "look", "run", "ingest", "reattest", "list-attestations", "attest",
105
105
  ]);
106
106
 
107
107
  function readPkgVersion() {
@@ -225,7 +225,11 @@ function main() {
225
225
 
226
226
  const resolver = COMMANDS[cmd];
227
227
  if (typeof resolver !== "function") {
228
- process.stderr.write(`exceptd: unknown command "${cmd}". Run \`exceptd help\` for the list.\n`);
228
+ // Emit a structured JSON error matching the seven-phase verbs so operators
229
+ // piping through `jq` get one consistent shape across the CLI surface.
230
+ // Plain-text "unknown command" still reaches stderr for human readers.
231
+ const err = { ok: false, error: `unknown command "${cmd}"`, hint: "Run `exceptd help` for the list of verbs.", verb: cmd };
232
+ process.stderr.write(JSON.stringify(err) + "\n");
229
233
  process.exit(2);
230
234
  }
231
235
 
@@ -335,8 +339,9 @@ function dispatchPlaybook(cmd, argv) {
335
339
  }
336
340
 
337
341
  const args = parseArgs(argv, {
338
- bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives"],
339
- multi: ["playbook"],
342
+ bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
343
+ "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack"],
344
+ multi: ["playbook", "format"],
340
345
  });
341
346
  const pretty = !!args.pretty;
342
347
  const runOpts = {
@@ -346,6 +351,15 @@ function dispatchPlaybook(cmd, argv) {
346
351
  if (args["session-id"]) runOpts.session_id = args["session-id"];
347
352
  if (args["session-key"]) runOpts.session_key = args["session-key"];
348
353
  if (args.mode) runOpts.mode = args.mode;
354
+ // Multi-operator teams need attestations bound to a specific human or
355
+ // service identity. --operator <name> persists into the attestation file
356
+ // for audit-trail accountability. Free-form string; no validation.
357
+ if (args.operator) runOpts.operator = args.operator;
358
+ // --ack: operator acknowledges the jurisdiction obligations surfaced by
359
+ // govern. Captured in attestation; downstream tooling can check whether
360
+ // consent was explicit vs. implicit. AGENTS.md says the AI should surface
361
+ // and wait for ack — this is how the ack gets recorded.
362
+ if (args.ack) runOpts.operator_consent = { acked_at: new Date().toISOString(), explicit: true };
349
363
 
350
364
  let runner;
351
365
  try {
@@ -365,6 +379,7 @@ function dispatchPlaybook(cmd, argv) {
365
379
  case "ingest": return cmdIngest(runner, args, runOpts, pretty);
366
380
  case "reattest": return cmdReattest(runner, args, runOpts, pretty);
367
381
  case "list-attestations": return cmdListAttestations(runner, args, runOpts, pretty);
382
+ case "attest": return cmdAttest(runner, args, runOpts, pretty);
368
383
  }
369
384
  } catch (e) {
370
385
  emitError(e.message, { verb: cmd }, pretty);
@@ -429,6 +444,34 @@ Flags:
429
444
  { artifacts, signal_overrides, signals, precondition_checks }
430
445
  Multi-playbook shape:
431
446
  { "<playbook_id>": { artifacts, ... }, ... }
447
+ --evidence-dir <dir> Read <playbook-id>.json files from a directory and
448
+ merge into the multi-run bundle. Cron-friendly.
449
+ --vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
450
+ not_affected | resolved | false_positive (CycloneDX)
451
+ or not_affected | fixed (OpenVEX) drop out of
452
+ analyze.matched_cves. The disposition is preserved
453
+ under analyze.vex.dropped_cves.
454
+ --format <fmt> ... Emit the close.evidence_package bundle in additional
455
+ formats. Repeatable. Supported: csaf-2.0 | sarif |
456
+ openvex | markdown. CSAF is always primary; extras
457
+ populate close.evidence_package.bundles_by_format.
458
+ --explain Dry-run: emit preconditions, required artifacts,
459
+ recognized signal keys, and a submission skeleton.
460
+ Does not run detect/analyze/validate/close.
461
+ --signal-list Emit only the signal_overrides keys the detect phase
462
+ recognizes (lighter than --explain).
463
+ --operator <name> Bind the attestation to a specific human/service
464
+ identity. Persisted under attestation.operator.
465
+ --ack Mark explicit operator consent to the jurisdiction
466
+ obligations surfaced by govern. Persisted under
467
+ attestation.operator_consent.
468
+ --diff-from-latest Compare evidence_hash against the most recent prior
469
+ attestation for the same playbook in
470
+ .exceptd/attestations/. Emits status: unchanged | drifted.
471
+ --ci Machine-readable verdict for CI gates. Exits non-zero
472
+ (code 2) when phases.detect.classification === 'detected'
473
+ OR phases.analyze.rwep.adjusted >= rwep_threshold.escalate.
474
+ Logs PASS/FAIL reason to stderr.
432
475
  --session-id <id> Reuse a specific session ID.
433
476
  --session-key <hex> HMAC sign the evidence_package with this key.
434
477
  --force-stale Override the threat_currency_score < 50 hard-block.
@@ -444,13 +487,36 @@ Flags:
444
487
  --directive <id> Directive ID (overrides submission.directive_id).
445
488
  --evidence <file|-> Submission JSON. May include playbook_id/directive_id.
446
489
  --pretty Indented JSON output.`,
447
- reattest: `reattest <session-id> — replay a prior session and diff the evidence_hash.
490
+ reattest: `reattest [<session-id> | --latest] — replay a prior session and diff the evidence_hash.
448
491
 
449
492
  Args / flags:
450
- <session-id> Required positional. Looks under .exceptd/attestations/<id>/.
493
+ <session-id> Looks under .exceptd/attestations/<id>/attestation.json.
494
+ --latest Find the most-recent attestation automatically.
495
+ --playbook <id> Restrict --latest to a specific playbook.
496
+ --since <ISO> Restrict --latest to attestations after this ISO 8601 timestamp.
451
497
  --pretty Indented JSON output.
452
498
 
453
499
  Reports: unchanged | drifted | resolved from evidence_hash + classification deltas.`,
500
+ "list-attestations": `list-attestations [--playbook <id>] — enumerate prior attestations.
501
+
502
+ Args / flags:
503
+ --playbook <id> Filter to one playbook.
504
+ --pretty Indented JSON output.
505
+
506
+ Lists every attestation under .exceptd/attestations/<session_id>/, sorted
507
+ newest-first, with truncated evidence_hash + capture timestamp + file path.`,
508
+ attest: `attest <subverb> <session-id> — auditor-facing attestation operations.
509
+
510
+ Subverbs:
511
+ attest show <sid> Emit the full (unredacted) attestation.
512
+ attest export <sid> Emit redacted JSON suitable for audit submission.
513
+ Strips raw artifact values; preserves evidence_hash,
514
+ signature, classification, RWEP, remediation choice.
515
+ --format csaf wraps the export in a CSAF envelope.
516
+ attest verify <sid> Verify .sig sidecar against keys/public.pem.
517
+ Reports tamper status per attestation file.
518
+
519
+ All subverbs honor --pretty for indented JSON output.`,
454
520
  };
455
521
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
456
522
  }
@@ -475,13 +541,27 @@ function cmdPlan(runner, args, runOpts, pretty) {
475
541
  Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
476
542
  );
477
543
  }
478
- // --directives expands each playbook entry with its directive id + title +
479
- // applies_to so operators / AIs can pick a specific directive without
480
- // grepping playbook source.
544
+ // --directives expands each playbook entry with directive id + title +
545
+ // applies_to + description. v0.10.3-aware fallback: pull description from
546
+ // (a) explicit d.description, (b) directive override threat_context,
547
+ // (c) playbook-level direct.threat_context first sentence, (d) playbook
548
+ // domain.name. Operators need operator-facing prose, not just an ID + enum.
481
549
  if (args.directives) {
482
550
  for (const pb of plan.playbooks) {
483
551
  const full = runner.loadPlaybook(pb.id);
484
- pb.directives = full.directives.map(d => ({ id: d.id, title: d.title, applies_to: d.applies_to }));
552
+ const baseDirect = full.phases?.direct || {};
553
+ pb.directives = full.directives.map(d => {
554
+ const overrideDirect = d.phase_overrides?.direct || {};
555
+ const threatContext = overrideDirect.threat_context || baseDirect.threat_context || null;
556
+ const firstSentence = threatContext ? (threatContext.split(/(?<=[.!?])\s+/)[0] || "").slice(0, 240) : null;
557
+ return {
558
+ id: d.id,
559
+ title: d.title,
560
+ description: d.description || firstSentence || full.domain?.name || null,
561
+ applies_to: d.applies_to,
562
+ threat_context_preview: firstSentence,
563
+ };
564
+ });
485
565
  }
486
566
  }
487
567
  emit(plan, pretty);
@@ -583,6 +663,53 @@ function cmdRun(runner, args, runOpts, pretty) {
583
663
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
584
664
  if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
585
665
 
666
+ // --explain: dry-run that emits the preconditions + artifacts + indicators
667
+ // + signal keys the agent would need to supply, WITHOUT running detect/
668
+ // analyze/validate/close. Lets operators preview before assembling evidence.
669
+ if (args.explain) {
670
+ const lookPhase = runner.look(playbookId, directiveId, runOpts);
671
+ const detectPhase = runner.loadPlaybook(playbookId).phases?.detect || {};
672
+ const detectResolved = runner._resolvedPhase ? runner._resolvedPhase(pb, directiveId, "detect") : detectPhase;
673
+ emit({
674
+ verb: "run",
675
+ mode: "explain",
676
+ playbook_id: playbookId,
677
+ directive_id: directiveId,
678
+ scope: pb._meta?.scope || null,
679
+ preconditions: lookPhase.preconditions,
680
+ precondition_submission_shape: lookPhase.precondition_submission_shape,
681
+ artifacts_required: lookPhase.artifacts.filter(a => a.required).map(a => ({ id: a.id, type: a.type, source: a.source })),
682
+ artifacts_optional: lookPhase.artifacts.filter(a => !a.required).map(a => ({ id: a.id, type: a.type, source: a.source, fallback: lookPhase.fallback_if_unavailable.find(f => f.artifact_id === a.id) })),
683
+ signal_keys: (detectResolved.indicators || []).map(i => ({ id: i.id, type: i.type, deterministic: !!i.deterministic, confidence: i.confidence })),
684
+ detect_classification_override: { hint: "submit signals.detection_classification = 'detected' | 'inconclusive' | 'not_detected' | 'clean' to override engine-computed classification.", valid_values: ["detected", "inconclusive", "not_detected", "clean"] },
685
+ submission_skeleton: {
686
+ artifacts: Object.fromEntries(lookPhase.artifacts.map(a => [a.id, { value: "<your captured output>", captured: true }])),
687
+ signal_overrides: Object.fromEntries((detectResolved.indicators || []).map(i => [i.id, "hit | miss | inconclusive"])),
688
+ signals: { detection_classification: "<one of: detected|inconclusive|not_detected|clean>", theater_verdict: "<clear | theater | pending_agent_run>" },
689
+ precondition_checks: Object.fromEntries(lookPhase.preconditions.map(p => [p.id, true])),
690
+ }
691
+ }, pretty);
692
+ return;
693
+ }
694
+
695
+ // --signal-list: enumerate every signal_overrides key the detect phase
696
+ // recognizes. Lighter than --explain.
697
+ if (args["signal-list"]) {
698
+ const detectResolved = runner._resolvedPhase
699
+ ? runner._resolvedPhase(pb, directiveId, "detect")
700
+ : pb.phases?.detect;
701
+ emit({
702
+ verb: "run",
703
+ mode: "signal-list",
704
+ playbook_id: playbookId,
705
+ directive_id: directiveId,
706
+ signal_overrides_keys: (detectResolved?.indicators || []).map(i => i.id),
707
+ signal_value_grammar: "hit | miss | inconclusive",
708
+ detection_classification_override_keys: ["detected", "inconclusive", "not_detected", "clean"],
709
+ }, pretty);
710
+ return;
711
+ }
712
+
586
713
  let submission = {};
587
714
  if (args.evidence) {
588
715
  try {
@@ -598,6 +725,28 @@ function cmdRun(runner, args, runOpts, pretty) {
598
725
  runOpts.precondition_checks = submission.precondition_checks;
599
726
  }
600
727
 
728
+ // --format <fmt>: override the playbook's declared evidence_package.bundle_format.
729
+ // Supports csaf-2.0 | sarif | openvex | markdown. Multiple --format flags
730
+ // produce multiple bundles in the close response under bundles_by_format.
731
+ if (args.format) {
732
+ const formats = Array.isArray(args.format) ? args.format : [args.format];
733
+ submission.signals = submission.signals || {};
734
+ submission.signals._bundle_formats = formats;
735
+ }
736
+
737
+ // --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
738
+ // CVE ID set through to analyze() so matched_cves drops them.
739
+ if (args.vex) {
740
+ try {
741
+ const vexDoc = JSON.parse(fs.readFileSync(args.vex, "utf8"));
742
+ const vexSet = runner.vexFilterFromDoc(vexDoc);
743
+ submission.signals = submission.signals || {};
744
+ submission.signals.vex_filter = [...vexSet];
745
+ } catch (e) {
746
+ return emitError(`run: failed to load --vex ${args.vex}: ${e.message}`, null, pretty);
747
+ }
748
+ }
749
+
601
750
  const result = runner.run(playbookId, directiveId, submission, runOpts);
602
751
 
603
752
  // Persist attestation for reattest cycles when the run succeeded.
@@ -605,18 +754,23 @@ function cmdRun(runner, args, runOpts, pretty) {
605
754
  try {
606
755
  const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
607
756
  fs.mkdirSync(dir, { recursive: true });
608
- fs.writeFileSync(
609
- path.join(dir, "attestation.json"),
610
- JSON.stringify({
611
- session_id: result.session_id,
612
- playbook_id: result.playbook_id,
613
- directive_id: result.directive_id,
614
- evidence_hash: result.evidence_hash,
615
- submission,
616
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
617
- captured_at: new Date().toISOString(),
618
- }, null, 2)
619
- );
757
+ const attestation = {
758
+ session_id: result.session_id,
759
+ playbook_id: result.playbook_id,
760
+ directive_id: result.directive_id,
761
+ evidence_hash: result.evidence_hash,
762
+ operator: runOpts.operator || null,
763
+ operator_consent: runOpts.operator_consent || null,
764
+ submission,
765
+ run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
766
+ captured_at: new Date().toISOString(),
767
+ };
768
+ fs.writeFileSync(path.join(dir, "attestation.json"), JSON.stringify(attestation, null, 2));
769
+ // Feature #27: Ed25519-sign the attestation if .keys/private.pem exists
770
+ // (signing infrastructure is already shared with skill signing). Without
771
+ // a private key, write an unsigned `.sig` placeholder so tooling can tell
772
+ // the difference between "unsigned" and "tampered post-hoc".
773
+ maybeSignAttestation(path.join(dir, "attestation.json"));
620
774
  } catch { /* non-fatal — attestation persistence is best-effort */ }
621
775
  }
622
776
 
@@ -624,6 +778,68 @@ function cmdRun(runner, args, runOpts, pretty) {
624
778
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
625
779
  process.exit(1);
626
780
  }
781
+
782
+ // --diff-from-latest: compare evidence_hash against the most recent prior
783
+ // attestation for this playbook. Drift mode for cron baselines.
784
+ // We've already persisted the CURRENT attestation above, so the find must
785
+ // skip our session_id to get the actual prior one.
786
+ if (args["diff-from-latest"] && result && result.evidence_hash) {
787
+ const prior = findLatestAttestation({ playbookId, excludeSessionId: result.session_id });
788
+ if (prior) {
789
+ const priorHash = prior.parsed.evidence_hash;
790
+ result.diff_from_latest = {
791
+ prior_session_id: prior.parsed.session_id,
792
+ prior_captured_at: prior.parsed.captured_at,
793
+ prior_evidence_hash: priorHash,
794
+ new_evidence_hash: result.evidence_hash,
795
+ status: priorHash === result.evidence_hash ? "unchanged" : "drifted",
796
+ };
797
+ } else {
798
+ result.diff_from_latest = { status: "no_prior_attestation_for_playbook", playbook_id: playbookId };
799
+ }
800
+ }
801
+
802
+ // --ci: machine-readable verdict for CI gates.
803
+ //
804
+ // The detect phase classification is the host-specific signal — "is THIS
805
+ // environment exploitable for the catalogued CVEs". rwep.base is the
806
+ // worst-known catalog score for the domain, which is roughly constant
807
+ // regardless of the local environment; we don't fail CI on that alone or
808
+ // every CI run against a domain with KEV-listed catalog entries would
809
+ // perma-fail.
810
+ //
811
+ // Verdict:
812
+ // detected → FAIL (exit 2)
813
+ // inconclusive + rwep ≥ escalate
814
+ // → FAIL (the agent's evidence raised RWEP)
815
+ // not_detected → PASS (exit 0)
816
+ // inconclusive + rwep < escalate
817
+ // → PASS with WARN to stderr (visibility gap)
818
+ if (args.ci && result && result.phases) {
819
+ const classification = result.phases.detect && result.phases.detect.classification;
820
+ const rwep = result.phases.analyze && result.phases.analyze.rwep;
821
+ const threshold = rwep && rwep.threshold && rwep.threshold.escalate;
822
+ const adjusted = rwep && typeof rwep.adjusted === "number" ? rwep.adjusted : 0;
823
+ const escalate = typeof threshold === "number" && adjusted >= threshold;
824
+
825
+ emit(result, pretty);
826
+
827
+ if (classification === "detected") {
828
+ process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
829
+ process.exit(2);
830
+ }
831
+ if (classification === "inconclusive" && escalate) {
832
+ process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
833
+ process.exit(2);
834
+ }
835
+ if (classification === "inconclusive") {
836
+ process.stderr.write(`[exceptd run --ci] PASS+WARN: classification=inconclusive rwep=${adjusted} < threshold=${threshold} (visibility gap)\n`);
837
+ } else {
838
+ process.stderr.write(`[exceptd run --ci] PASS: classification=${classification} rwep=${adjusted}\n`);
839
+ }
840
+ return;
841
+ }
842
+
627
843
  emit(result, pretty);
628
844
  }
629
845
 
@@ -648,6 +864,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
648
864
  return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
649
865
  }
650
866
  }
867
+ // --evidence-dir <dir>: each <playbook-id>.json under the directory is read
868
+ // as that playbook's submission. Lets operators wire up one cron job that
869
+ // collects per-playbook evidence into a directory, then runs the whole
870
+ // contract in one pass.
871
+ if (args["evidence-dir"]) {
872
+ const dir = args["evidence-dir"];
873
+ if (!fs.existsSync(dir)) {
874
+ return emitError(`run: --evidence-dir ${dir} does not exist.`, null, pretty);
875
+ }
876
+ for (const f of fs.readdirSync(dir).filter(x => x.endsWith(".json"))) {
877
+ const pbId = f.replace(/\.json$/, "");
878
+ try {
879
+ bundle[pbId] = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
880
+ } catch (e) {
881
+ return emitError(`run: failed to parse --evidence-dir entry ${f}: ${e.message}`, null, pretty);
882
+ }
883
+ }
884
+ }
651
885
 
652
886
  const results = [];
653
887
  for (const id of ids) {
@@ -668,18 +902,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
668
902
  try {
669
903
  const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
670
904
  fs.mkdirSync(dir, { recursive: true });
671
- fs.writeFileSync(
672
- path.join(dir, `${id}.json`),
673
- JSON.stringify({
674
- session_id: sessionId,
675
- playbook_id: id,
676
- directive_id: directiveId,
677
- evidence_hash: result.evidence_hash,
678
- submission,
679
- run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
680
- captured_at: new Date().toISOString(),
681
- }, null, 2)
682
- );
905
+ const filePath = path.join(dir, `${id}.json`);
906
+ fs.writeFileSync(filePath, JSON.stringify({
907
+ session_id: sessionId,
908
+ playbook_id: id,
909
+ directive_id: directiveId,
910
+ evidence_hash: result.evidence_hash,
911
+ operator: perRunOpts.operator || null,
912
+ operator_consent: perRunOpts.operator_consent || null,
913
+ submission,
914
+ run_opts: { airGap: perRunOpts.airGap, forceStale: perRunOpts.forceStale, mode: perRunOpts.mode },
915
+ captured_at: new Date().toISOString(),
916
+ }, null, 2));
917
+ maybeSignAttestation(filePath);
683
918
  } catch { /* non-fatal */ }
684
919
  }
685
920
  results.push(result);
@@ -760,11 +995,94 @@ function cmdIngest(runner, args, runOpts, pretty) {
760
995
  emit(result, pretty);
761
996
  }
762
997
 
998
+ /**
999
+ * Ed25519-sign an attestation file when .keys/private.pem is available
1000
+ * (matches lib/sign.js convention for skill signing). Writes a sidecar
1001
+ * `<file>.sig` alongside the attestation. Defense against post-hoc tampering
1002
+ * by anyone who can write to .exceptd/.
1003
+ *
1004
+ * Without a private key, writes a marker file documenting the signed=false
1005
+ * state so downstream tooling can distinguish "operator declined signing"
1006
+ * from "the .sig file was deleted by an attacker."
1007
+ */
1008
+ function maybeSignAttestation(filePath) {
1009
+ const crypto = require("crypto");
1010
+ const sigPath = filePath + ".sig";
1011
+ const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
1012
+ const content = fs.readFileSync(filePath, "utf8");
1013
+ try {
1014
+ if (fs.existsSync(privKeyPath)) {
1015
+ const privateKey = fs.readFileSync(privKeyPath, "utf8");
1016
+ const sig = crypto.sign(null, Buffer.from(content, "utf8"), {
1017
+ key: privateKey,
1018
+ dsaEncoding: "ieee-p1363",
1019
+ });
1020
+ fs.writeFileSync(sigPath, JSON.stringify({
1021
+ algorithm: "Ed25519",
1022
+ signature_base64: sig.toString("base64"),
1023
+ signed_at: new Date().toISOString(),
1024
+ signs_path: path.basename(filePath),
1025
+ signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
1026
+ }, null, 2));
1027
+ } else {
1028
+ fs.writeFileSync(sigPath, JSON.stringify({
1029
+ algorithm: "unsigned",
1030
+ signed: false,
1031
+ signed_at: null,
1032
+ signs_path: path.basename(filePath),
1033
+ signs_sha256: crypto.createHash("sha256").update(content).digest("base64"),
1034
+ note: "No private key at .keys/private.pem — attestation is hash-stable but unsigned. Run `node lib/sign.js generate-keypair` to enable signing.",
1035
+ }, null, 2));
1036
+ }
1037
+ } catch { /* non-fatal — signing failure shouldn't block the run */ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Find the latest attestation file under .exceptd/attestations/.
1042
+ * Filters: optional playbook ID and optional "since" ISO timestamp.
1043
+ * Returns { sessionId, playbookId, file, parsed } or null.
1044
+ */
1045
+ function findLatestAttestation(opts = {}) {
1046
+ const root = path.join(process.cwd(), ".exceptd", "attestations");
1047
+ if (!fs.existsSync(root)) return null;
1048
+ const sessions = fs.readdirSync(root, { withFileTypes: true })
1049
+ .filter(d => d.isDirectory())
1050
+ .map(d => d.name);
1051
+ const candidates = [];
1052
+ for (const sid of sessions) {
1053
+ const sdir = path.join(root, sid);
1054
+ for (const f of fs.readdirSync(sdir).filter(x => x.endsWith(".json"))) {
1055
+ try {
1056
+ const p = path.join(sdir, f);
1057
+ const j = JSON.parse(fs.readFileSync(p, "utf8"));
1058
+ if (opts.playbookId && j.playbook_id !== opts.playbookId) continue;
1059
+ if (opts.since && (j.captured_at || "") < opts.since) continue;
1060
+ if (opts.excludeSessionId && sid === opts.excludeSessionId) continue;
1061
+ candidates.push({ sessionId: sid, playbookId: j.playbook_id, file: p, parsed: j });
1062
+ } catch { /* skip malformed */ }
1063
+ }
1064
+ }
1065
+ candidates.sort((a, b) => (b.parsed.captured_at || "").localeCompare(a.parsed.captured_at || ""));
1066
+ return candidates[0] || null;
1067
+ }
1068
+
763
1069
  function cmdReattest(runner, args, runOpts, pretty) {
764
- const sessionId = args._[0];
765
- if (!sessionId) return emitError("reattest: missing <session-id> positional argument.", null, pretty);
1070
+ // --latest [--playbook <id>] [--since <ISO>] — find prior attestation
1071
+ // without requiring the operator to know the session-id.
1072
+ let sessionId = args._[0];
1073
+ let attFile = null;
1074
+ if (!sessionId && args.latest) {
1075
+ const found = findLatestAttestation({
1076
+ playbookId: args.playbook ? (Array.isArray(args.playbook) ? args.playbook[0] : args.playbook) : null,
1077
+ since: args.since || null,
1078
+ });
1079
+ if (!found) return emitError("reattest: --latest found no matching attestations.", { filter: { playbook: args.playbook || null, since: args.since || null } }, pretty);
1080
+ sessionId = found.sessionId;
1081
+ attFile = found.file;
1082
+ }
1083
+ if (!sessionId) return emitError("reattest: missing <session-id>. Pass a session-id or --latest [--playbook <id>] [--since <ISO>].", null, pretty);
766
1084
  const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
767
- const attFile = path.join(dir, "attestation.json");
1085
+ if (!attFile) attFile = path.join(dir, "attestation.json");
768
1086
  if (!fs.existsSync(attFile)) {
769
1087
  return emitError(`reattest: no attestation found at ${path.relative(process.cwd(), attFile)}`, { session_id: sessionId }, pretty);
770
1088
  }
@@ -836,6 +1154,119 @@ function cmdReattest(runner, args, runOpts, pretty) {
836
1154
  }, pretty);
837
1155
  }
838
1156
 
1157
+ /**
1158
+ * `exceptd attest <subverb> <session-id>` — auditor-facing operations on
1159
+ * persisted attestations. Subverbs:
1160
+ * export <session-id> Emit redacted JSON suitable for audit submission.
1161
+ * Strips raw artifact values; preserves only
1162
+ * evidence_hash + signatures + classification + RWEP.
1163
+ * Falls back to a CSAF-shaped envelope when --format csaf.
1164
+ * verify <session-id> Verify the .sig sidecar against keys/public.pem.
1165
+ * Reports signed_by + tamper status.
1166
+ * show <session-id> Emit the full (unredacted) attestation. Convenience
1167
+ * alias for `cat .exceptd/attestations/<sid>/attestation.json`.
1168
+ */
1169
+ function cmdAttest(runner, args, runOpts, pretty) {
1170
+ const subverb = args._[0];
1171
+ const sessionId = args._[1];
1172
+ if (!subverb) {
1173
+ return emitError("attest: missing subverb. Usage: attest export|verify|show <session-id>", null, pretty);
1174
+ }
1175
+ if (!sessionId) {
1176
+ return emitError(`attest ${subverb}: missing <session-id> positional argument.`, null, pretty);
1177
+ }
1178
+ const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
1179
+ if (!fs.existsSync(dir)) {
1180
+ return emitError(`attest ${subverb}: no session dir at ${path.relative(process.cwd(), dir)}`, { session_id: sessionId }, pretty);
1181
+ }
1182
+
1183
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".json") && !f.endsWith(".sig"));
1184
+ const attestations = files.map(f => {
1185
+ try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); }
1186
+ catch { return null; }
1187
+ }).filter(Boolean);
1188
+
1189
+ if (subverb === "show") {
1190
+ emit({ session_id: sessionId, attestations }, pretty);
1191
+ return;
1192
+ }
1193
+
1194
+ if (subverb === "verify") {
1195
+ const crypto = require("crypto");
1196
+ const pubKeyPath = path.join(PKG_ROOT, "keys", "public.pem");
1197
+ const pubKey = fs.existsSync(pubKeyPath) ? fs.readFileSync(pubKeyPath, "utf8") : null;
1198
+ const results = files.map(f => {
1199
+ const sigPath = path.join(dir, f + ".sig");
1200
+ if (!fs.existsSync(sigPath)) return { file: f, signed: false, verified: false, reason: "no .sig sidecar" };
1201
+ const sigDoc = JSON.parse(fs.readFileSync(sigPath, "utf8"));
1202
+ if (sigDoc.algorithm === "unsigned") return { file: f, signed: false, verified: false, reason: "attestation explicitly unsigned (no private key when written)" };
1203
+ if (!pubKey) return { file: f, signed: true, verified: false, reason: "no public key at keys/public.pem to verify against" };
1204
+ const content = fs.readFileSync(path.join(dir, f), "utf8");
1205
+ try {
1206
+ const ok = crypto.verify(null, Buffer.from(content, "utf8"), {
1207
+ key: pubKey, dsaEncoding: "ieee-p1363",
1208
+ }, Buffer.from(sigDoc.signature_base64, "base64"));
1209
+ return { file: f, signed: true, verified: !!ok, reason: ok ? "Ed25519 signature valid" : "Ed25519 signature INVALID — possible post-hoc tampering" };
1210
+ } catch (e) {
1211
+ return { file: f, signed: true, verified: false, reason: `verify error: ${e.message}` };
1212
+ }
1213
+ });
1214
+ emit({ verb: "attest verify", session_id: sessionId, results }, pretty);
1215
+ return;
1216
+ }
1217
+
1218
+ if (subverb === "export") {
1219
+ // Redaction: strip raw `value` fields from submitted artifacts; preserve
1220
+ // captured-state flag, evidence_hash, classification, RWEP, confidence,
1221
+ // remediation choice, residual risk acceptance, signature. Auditors get
1222
+ // what they need (the verdict + proof of process) without leaking raw
1223
+ // captured data (which may contain PII / secret shapes).
1224
+ const format = args.format || "json";
1225
+ const redacted = attestations.map(a => ({
1226
+ session_id: a.session_id,
1227
+ playbook_id: a.playbook_id,
1228
+ directive_id: a.directive_id,
1229
+ evidence_hash: a.evidence_hash,
1230
+ operator: a.operator,
1231
+ operator_consent: a.operator_consent,
1232
+ captured_at: a.captured_at,
1233
+ run_opts: a.run_opts,
1234
+ artifacts_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.artifacts) || {})
1235
+ .map(([k, v]) => [k, { captured: !!v.captured, reason: v.reason || null, redacted_value: "[redacted]" }])),
1236
+ signal_overrides: (a.submission && a.submission.signal_overrides) || {},
1237
+ signals_redacted: Object.fromEntries(Object.entries((a.submission && a.submission.signals) || {})
1238
+ .filter(([k]) => !/_filter$|_key$|token|secret|password/i.test(k))),
1239
+ precondition_checks: (a.submission && a.submission.precondition_checks) || {},
1240
+ }));
1241
+
1242
+ if (format === "csaf") {
1243
+ // Lightweight CSAF envelope for audit submission — caller can post this
1244
+ // directly to a CSAF-aware GRC platform.
1245
+ emit({
1246
+ document: {
1247
+ category: "csaf_security_advisory",
1248
+ csaf_version: "2.0",
1249
+ publisher: { category: "vendor", name: "exceptd", namespace: "https://exceptd.com" },
1250
+ title: `Auditor export — session ${sessionId}`,
1251
+ tracking: { id: `exceptd-export-${sessionId}`, status: "final", version: "1", initial_release_date: new Date().toISOString() },
1252
+ },
1253
+ exceptd_export: { session_id: sessionId, attestations: redacted, exported_at: new Date().toISOString(), redaction_policy: "v0.10.3-default" },
1254
+ }, pretty);
1255
+ } else {
1256
+ emit({
1257
+ verb: "attest export",
1258
+ session_id: sessionId,
1259
+ exported_at: new Date().toISOString(),
1260
+ redaction_policy: "v0.10.3-default — artifact values stripped; signal_overrides + precondition_checks + evidence_hash + signature preserved.",
1261
+ attestations: redacted,
1262
+ }, pretty);
1263
+ }
1264
+ return;
1265
+ }
1266
+
1267
+ return emitError(`attest: unknown subverb "${subverb}". Try export | verify | show.`, null, pretty);
1268
+ }
1269
+
839
1270
  function cmdListAttestations(runner, args, runOpts, pretty) {
840
1271
  const root = path.join(process.cwd(), ".exceptd", "attestations");
841
1272
  if (!fs.existsSync(root)) {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-12T13:36:15.948Z",
3
+ "generated_at": "2026-05-12T14:08:27.651Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 49,
6
6
  "source_hashes": {
7
- "manifest.json": "f71c110a187ffaf19db2bbbb1f15ceb27f88bbb844d81329c21ba6705badac8a",
7
+ "manifest.json": "35c0e5e79c31c68c0a45e0bcad009fd34c935df5d0ba738e1f106a9be0c3d357",
8
8
  "data/atlas-ttps.json": "1500b5830dab070c4252496964a8c0948e1052a656e2c7c6e1efaf0350645e13",
9
9
  "data/cve-catalog.json": "a81d3e4b491b27ccc084596b063a6108ff10c9eb01d7776922fc393980b534fe",
10
10
  "data/cwe-catalog.json": "c3367d469b4b3d31e4c56397dd7a8305a0be338ecd85afa27804c0c9ce12157b",