@blamejs/exceptd-skills 0.10.2 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.3 — 2026-05-12
4
+
5
+ **Patch: 14 operator-reported items — 5 bugs + 9 features.**
6
+
7
+ ### Bugs
8
+
9
+ 1. **`exceptd validate-cves` crashed with `MODULE_NOT_FOUND`** in the installed npm package because `sources/` wasn't in the `files` allowlist. Two-part fix: (a) `sources/validators/` added to `package.json` `files`; (b) `runValidateCves` now wraps the require in the same try/catch graceful-fallback pattern `runValidateRfcs` was already using, so the command degrades to offline mode instead of crashing.
10
+ 2. **Inconsistent error shapes across verbs.** `exceptd <unknown>` and `exceptd skill <missing>` emitted plain stderr text while seven-phase verbs emitted structured JSON. Unified: every CLI verb now emits `{ok:false,error,hint,verb}` JSON on error so operators piping through `jq` get one shape.
11
+ 3. **`prefetch --no-network --quiet` was completely silent on success.** Now emits a one-line `prefetch summary: …` unconditionally; `--quiet` suppresses only the per-entry chatter.
12
+ 4. **`plan --directives` exposed `id + title + applies_to` only — no `description`.** Now also surfaces a `description` field (falls back through explicit `directive.description` → `phase_overrides.direct.threat_context` → playbook-level `direct.threat_context` first sentence → `domain.name`) plus a `threat_context_preview`. Operators / AIs get operator-facing prose, not just an ID + enum.
13
+ 5. **Analyst verbs (`scan`/`dispatch`/`currency`/`watchlist`/`report`) defaulted to human-readable text** while every seven-phase verb defaulted to JSON. Added `--json` flag passthrough across all analyst verbs. Operators scripting around both surfaces now have a consistent switch.
14
+
15
+ ### Features
16
+
17
+ 6. **`run --explain` dry-run** — emits preconditions, required + optional artifacts (with fallback notes), recognized signal keys with types + deterministic flags, and a `submission_skeleton` JSON the operator can fill in. No detect/analyze/validate/close happens. Lets operators preview before assembling evidence.
18
+ 7. **`attest <subverb> <session-id>`** — `attest export` emits redacted JSON for audit submission (strips raw artifact values, preserves evidence_hash + signature + classification + RWEP + remediation choice + residual risk acceptance). `--format csaf` wraps the export in a CSAF envelope. `attest verify` checks the `.sig` sidecar against `keys/public.pem` and reports tamper status. `attest show` emits the full unredacted attestation.
19
+ 8. **`run --signal-list`** — lighter than `--explain`; enumerates only the signal_overrides keys the detect phase recognizes plus the four valid `detection_classification` values. Closes the "agent submits a key and runner silently ignores it" gap (v0.10.1 bug #5).
20
+ 9. **Continuous-compliance: `run --evidence-dir <dir>`** — each `<playbook-id>.json` under the directory becomes that playbook's submission in a multi-playbook run. One cron job → full posture in one CSAF bundle. Pairs with `run --all`.
21
+ 10. **`validate-cves` + `validate-rfcs` gained `--since <ISO|YYYY-MM-DD>`** — scope-limit validation to entries whose `last_updated` / `cisa_kev_date` / `last_verified` / `published` is on or after the date. Cuts upstream calls for fleet operators running cron.
22
+ 11. **Ed25519-signed attestations** — every `attestation.json` now gets a `<file>.sig` sidecar. With `.keys/private.pem` present, the runner signs (matches the existing skill-signing convention). Without a private key, writes an `unsigned` marker file so downstream tooling can distinguish "operator declined signing" from "the .sig file was deleted by an attacker." `attest verify` cross-checks the signature against `keys/public.pem`.
23
+ 12. **`run --operator <name>`** — binds the attestation to a specific human or service identity. Persisted under `attestation.operator` for multi-operator audit-trail accountability.
24
+ 13. **`run --ack`** — explicit operator consent to the jurisdiction obligations surfaced by `govern`. Persisted under `attestation.operator_consent = { acked_at, explicit: true }`. Without `--ack`, the field is null (consent implicit / unverified).
25
+ 14. **`run --format <fmt>` repeatable** — emit the close.evidence_package in additional formats alongside the playbook-declared primary. Supported: `csaf-2.0` (primary), `sarif` (2.1.0 — GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps), `openvex` (0.2.0 — sigstore / in-toto / GUAC consumers), `markdown` (human review). Extras populate `close.evidence_package.bundles_by_format`.
26
+
27
+ ### Internal
28
+
29
+ - `lib/playbook-runner.js` `buildEvidenceBundle` now handles `csaf-2.0`, `sarif` (with per-CVE rules + properties), `openvex` (with status derived from active_exploitation + live_patch_available), and `markdown`.
30
+ - `bin/exceptd.js` `maybeSignAttestation` helper uses the same Ed25519 primitive as `lib/sign.js` against `.keys/private.pem`.
31
+ - CSAF envelope cvss_v3.base_score now reflects the catalog's real cvss_score (previously hardcoded 0).
32
+ - `submission.signals._bundle_formats` is the agent-side hook for requesting extra formats.
33
+
3
34
  ## 0.10.2 — 2026-05-12
4
35
 
5
36
  **Patch: v0.10.1 deferred set — framework-gap filter fix, VEX consumption, CI gating, drift mode, 2 new playbooks (13 total), feeds_into matrix.**
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", "ci", "latest", "diff-from-latest"],
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,11 +444,27 @@ 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.
432
449
  --vex <file> Load a CycloneDX or OpenVEX document. CVEs marked
433
450
  not_affected | resolved | false_positive (CycloneDX)
434
451
  or not_affected | fixed (OpenVEX) drop out of
435
452
  analyze.matched_cves. The disposition is preserved
436
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.
437
468
  --diff-from-latest Compare evidence_hash against the most recent prior
438
469
  attestation for the same playbook in
439
470
  .exceptd/attestations/. Emits status: unchanged | drifted.
@@ -474,6 +505,18 @@ Args / flags:
474
505
 
475
506
  Lists every attestation under .exceptd/attestations/<session_id>/, sorted
476
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.`,
477
520
  };
478
521
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
479
522
  }
@@ -498,13 +541,27 @@ function cmdPlan(runner, args, runOpts, pretty) {
498
541
  Object.entries(plan.grouped_by_scope).map(([s, list]) => [s, list.length])
499
542
  );
500
543
  }
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.
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.
504
549
  if (args.directives) {
505
550
  for (const pb of plan.playbooks) {
506
551
  const full = runner.loadPlaybook(pb.id);
507
- 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
+ });
508
565
  }
509
566
  }
510
567
  emit(plan, pretty);
@@ -606,6 +663,53 @@ function cmdRun(runner, args, runOpts, pretty) {
606
663
  const directiveId = args.directive || (pb.directives[0] && pb.directives[0].id);
607
664
  if (!directiveId) return emitError(`run: playbook ${playbookId} has no directives.`, null, pretty);
608
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
+
609
713
  let submission = {};
610
714
  if (args.evidence) {
611
715
  try {
@@ -621,6 +725,15 @@ function cmdRun(runner, args, runOpts, pretty) {
621
725
  runOpts.precondition_checks = submission.precondition_checks;
622
726
  }
623
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
+
624
737
  // --vex <file>: load a CycloneDX/OpenVEX document and pass the not_affected
625
738
  // CVE ID set through to analyze() so matched_cves drops them.
626
739
  if (args.vex) {
@@ -641,18 +754,23 @@ function cmdRun(runner, args, runOpts, pretty) {
641
754
  try {
642
755
  const dir = path.join(process.cwd(), ".exceptd", "attestations", result.session_id);
643
756
  fs.mkdirSync(dir, { recursive: true });
644
- fs.writeFileSync(
645
- path.join(dir, "attestation.json"),
646
- JSON.stringify({
647
- session_id: result.session_id,
648
- playbook_id: result.playbook_id,
649
- directive_id: result.directive_id,
650
- evidence_hash: result.evidence_hash,
651
- submission,
652
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
653
- captured_at: new Date().toISOString(),
654
- }, null, 2)
655
- );
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"));
656
774
  } catch { /* non-fatal — attestation persistence is best-effort */ }
657
775
  }
658
776
 
@@ -746,6 +864,24 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
746
864
  return emitError(`run: failed to read evidence bundle: ${e.message}`, { evidence: args.evidence }, pretty);
747
865
  }
748
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
+ }
749
885
 
750
886
  const results = [];
751
887
  for (const id of ids) {
@@ -766,18 +902,19 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
766
902
  try {
767
903
  const dir = path.join(process.cwd(), ".exceptd", "attestations", sessionId);
768
904
  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
- );
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);
781
918
  } catch { /* non-fatal */ }
782
919
  }
783
920
  results.push(result);
@@ -858,6 +995,48 @@ function cmdIngest(runner, args, runOpts, pretty) {
858
995
  emit(result, pretty);
859
996
  }
860
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
+
861
1040
  /**
862
1041
  * Find the latest attestation file under .exceptd/attestations/.
863
1042
  * Filters: optional playbook ID and optional "since" ISO timestamp.
@@ -975,6 +1154,119 @@ function cmdReattest(runner, args, runOpts, pretty) {
975
1154
  }, pretty);
976
1155
  }
977
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
+
978
1270
  function cmdListAttestations(runner, args, runOpts, pretty) {
979
1271
  const root = path.join(process.cwd(), ".exceptd", "attestations");
980
1272
  if (!fs.existsSync(root)) {
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-12T13:50:32.319Z",
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": "3614ad87835688365283c95a8b7af1d66f14928c07efef750c206ba2f13aab33",
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",
@@ -586,13 +586,23 @@ function close(playbookId, directiveId, analyzeResult, validateResult, agentSign
586
586
  }
587
587
  }
588
588
 
589
- // evidence_package
589
+ // evidence_package — playbook declares one primary bundle_format; the
590
+ // operator may request additional formats via agentSignals._bundle_formats
591
+ // (e.g. SARIF for GitHub Code Scanning + OpenVEX for supply-chain tooling
592
+ // alongside the CSAF default).
593
+ const primaryFormat = c.evidence_package?.bundle_format || 'csaf-2.0';
594
+ const extraFormats = Array.isArray(agentSignals._bundle_formats)
595
+ ? agentSignals._bundle_formats.filter(f => f !== primaryFormat)
596
+ : [];
590
597
  const evidencePackage = c.evidence_package ? {
591
- bundle_format: c.evidence_package.bundle_format || 'csaf-2.0',
598
+ bundle_format: primaryFormat,
592
599
  contents: c.evidence_package.contents || [],
593
600
  destination: c.evidence_package.destination || 'local_only',
594
601
  signed: c.evidence_package.signed !== false,
595
- bundle_body: buildEvidenceBundle(c.evidence_package.bundle_format || 'csaf-2.0', playbook, analyzeResult, validateResult, agentSignals)
602
+ bundle_body: buildEvidenceBundle(primaryFormat, playbook, analyzeResult, validateResult, agentSignals),
603
+ bundles_by_format: extraFormats.length ? Object.fromEntries(
604
+ [primaryFormat, ...extraFormats].map(f => [f, buildEvidenceBundle(f, playbook, analyzeResult, validateResult, agentSignals)])
605
+ ) : null,
596
606
  } : null;
597
607
 
598
608
  if (evidencePackage && evidencePackage.signed && runOpts.session_key) {
@@ -687,22 +697,102 @@ function buildEvidenceBundle(format, playbook, analyze, validate, agentSignals)
687
697
  },
688
698
  vulnerabilities: analyze.matched_cves.map(c => ({
689
699
  cve: c.cve_id,
690
- scores: [{ products: [], cvss_v3: { base_score: 0 } }],
700
+ scores: [{ products: [], cvss_v3: { base_score: c.cvss_score || 0 } }],
691
701
  threats: c.active_exploitation === 'confirmed' ? [{ category: 'exploit_status', details: 'Active exploitation confirmed (CISA KEV).' }] : [],
692
702
  remediations: [{ category: 'vendor_fix', details: validate.selected_remediation?.description || 'See selected remediation path.' }]
693
703
  })),
694
704
  exceptd_extension: {
695
705
  rwep: analyze.rwep,
696
706
  blast_radius_score: analyze.blast_radius_score,
697
- compliance_theater: analyze.compliance_theater,
707
+ compliance_theater: analyze.compliance_theater_check,
698
708
  framework_gap_mapping: analyze.framework_gap_mapping,
699
709
  evidence_requirements: validate.evidence_requirements,
700
710
  residual_risk_statement: validate.residual_risk_statement
701
711
  }
702
712
  };
703
713
  }
704
- // Other formats deferred.
705
- return { format, note: 'Non-CSAF formats deferred to GRC integration layer.', analyze, validate };
714
+
715
+ // SARIF 2.1.0 GitHub Code Scanning / VS Code SARIF Viewer / Azure DevOps
716
+ // and most static analysis tooling. One run per playbook directive, one
717
+ // result per matched CVE. Each result references a rule (cve_id) and ties
718
+ // back to the directive as the "tool" producer.
719
+ if (format === 'sarif' || format === 'sarif-2.1.0') {
720
+ return {
721
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
722
+ version: '2.1.0',
723
+ runs: [{
724
+ tool: {
725
+ driver: {
726
+ name: 'exceptd',
727
+ version: playbook._meta.version,
728
+ informationUri: 'https://exceptd.com',
729
+ rules: analyze.matched_cves.map(c => ({
730
+ id: c.cve_id,
731
+ shortDescription: { text: c.cve_id },
732
+ fullDescription: { text: `RWEP ${c.rwep} · KEV=${c.cisa_kev} · active_exploitation=${c.active_exploitation} · PoC=${c.poc_available}` },
733
+ defaultConfiguration: { level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note' },
734
+ helpUri: `https://nvd.nist.gov/vuln/detail/${c.cve_id}`,
735
+ }))
736
+ }
737
+ },
738
+ results: analyze.matched_cves.map(c => ({
739
+ ruleId: c.cve_id,
740
+ level: c.rwep >= 90 ? 'error' : c.rwep >= 70 ? 'warning' : 'note',
741
+ message: { text: `${c.cve_id}: RWEP ${c.rwep}, blast_radius ${analyze.blast_radius_score}. ${validate.selected_remediation?.description || ''}` },
742
+ properties: {
743
+ rwep: c.rwep,
744
+ cisa_kev: c.cisa_kev,
745
+ cisa_kev_due_date: c.cisa_kev_due_date,
746
+ active_exploitation: c.active_exploitation,
747
+ ai_discovered: c.ai_discovered,
748
+ blast_radius_score: analyze.blast_radius_score,
749
+ framework_gaps: analyze.framework_gap_mapping?.length || 0,
750
+ }
751
+ }))
752
+ }]
753
+ };
754
+ }
755
+
756
+ // OpenVEX 0.2.0 — supply-chain VEX statements. Each matched CVE becomes a
757
+ // statement with status derived from confidence + RWEP. Downstream tools
758
+ // (sigstore, in-toto, GUAC) consume this directly.
759
+ if (format === 'openvex' || format === 'openvex-0.2.0') {
760
+ const issued = new Date().toISOString();
761
+ return {
762
+ '@context': 'https://openvex.dev/ns/v0.2.0',
763
+ '@id': `https://exceptd.com/vex/${playbook._meta.id}/${Date.now()}`,
764
+ author: 'exceptd',
765
+ timestamp: issued,
766
+ version: 1,
767
+ statements: analyze.matched_cves.map(c => ({
768
+ vulnerability: { '@id': c.cve_id, name: c.cve_id },
769
+ status: c.active_exploitation === 'confirmed' ? 'under_investigation' : (c.live_patch_available ? 'fixed' : 'affected'),
770
+ timestamp: issued,
771
+ action_statement: validate.selected_remediation?.description || null,
772
+ impact_statement: `RWEP ${c.rwep}. Blast radius ${analyze.blast_radius_score}/5.`
773
+ }))
774
+ };
775
+ }
776
+
777
+ if (format === 'markdown') {
778
+ const lines = [
779
+ `# exceptd finding: ${playbook.domain.name}`,
780
+ `**Playbook:** ${playbook._meta.id} v${playbook._meta.version}`,
781
+ `**Matched CVEs:** ${analyze.matched_cves.length}`,
782
+ `**Top RWEP:** ${analyze.rwep?.adjusted || 0}`,
783
+ `**Blast radius:** ${analyze.blast_radius_score || 'unknown'}/5`,
784
+ `**Theater verdict:** ${analyze.compliance_theater_check?.verdict || 'n/a'}`,
785
+ `\n## Matched CVEs`,
786
+ ...analyze.matched_cves.map(c => `- **${c.cve_id}** RWEP ${c.rwep} · KEV=${c.cisa_kev} · ${c.active_exploitation}`),
787
+ `\n## Selected remediation`,
788
+ validate.selected_remediation ? `${validate.selected_remediation.id} (priority ${validate.selected_remediation.priority}): ${validate.selected_remediation.description}` : 'No remediation path selected.',
789
+ `\n## Residual risk`,
790
+ validate.residual_risk_statement ? `${validate.residual_risk_statement.risk}\n\n_Acceptance level: ${validate.residual_risk_statement.acceptance_level}_` : 'None recorded.',
791
+ ];
792
+ return { format: 'markdown', body: lines.join('\n') };
793
+ }
794
+
795
+ return { format, note: 'Unknown format — supported: csaf-2.0, sarif, openvex, markdown.', analyze, validate };
706
796
  }
707
797
 
708
798
  // --- orchestrate: full run in one call ---