@blamejs/exceptd-skills 0.12.7 → 0.12.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/AGENTS.md +15 -1
  2. package/ARCHITECTURE.md +21 -5
  3. package/CHANGELOG.md +150 -0
  4. package/README.md +1 -1
  5. package/bin/exceptd.js +416 -69
  6. package/data/_indexes/_meta.json +44 -44
  7. package/data/_indexes/activity-feed.json +34 -34
  8. package/data/_indexes/catalog-summaries.json +9 -9
  9. package/data/_indexes/chains.json +249 -11
  10. package/data/_indexes/frequency.json +63 -5
  11. package/data/_indexes/jurisdiction-map.json +13 -3
  12. package/data/_indexes/section-offsets.json +1171 -1027
  13. package/data/_indexes/summary-cards.json +2 -2
  14. package/data/_indexes/token-budget.json +232 -152
  15. package/data/atlas-ttps.json +189 -1
  16. package/data/cve-catalog.json +34 -22
  17. package/data/cwe-catalog.json +290 -1
  18. package/data/d3fend-catalog.json +163 -1
  19. package/data/framework-control-gaps.json +243 -0
  20. package/data/playbooks/containers.json +23 -5
  21. package/data/playbooks/cred-stores.json +9 -9
  22. package/data/playbooks/crypto.json +8 -8
  23. package/data/playbooks/hardening.json +46 -10
  24. package/data/playbooks/library-author.json +16 -20
  25. package/data/playbooks/mcp.json +1 -0
  26. package/data/playbooks/runtime.json +7 -7
  27. package/data/playbooks/sbom.json +11 -11
  28. package/data/playbooks/secrets.json +4 -4
  29. package/data/rfc-references.json +144 -0
  30. package/lib/playbook-runner.js +119 -35
  31. package/lib/prefetch.js +27 -6
  32. package/lib/refresh-external.js +32 -9
  33. package/lib/schemas/skill-frontmatter.schema.json +2 -2
  34. package/manifest-snapshot.json +1 -1
  35. package/manifest.json +73 -73
  36. package/orchestrator/index.js +1 -1
  37. package/package.json +2 -1
  38. package/sbom.cdx.json +6 -6
  39. package/scripts/check-sbom-currency.js +87 -0
  40. package/scripts/check-test-coverage.README.md +148 -0
  41. package/scripts/check-test-coverage.js +476 -0
  42. package/scripts/hooks/pre-commit.sh +19 -0
  43. package/scripts/predeploy.js +14 -30
  44. package/skills/age-gates-child-safety/skill.md +3 -0
  45. package/skills/ai-attack-surface/skill.md +29 -1
  46. package/skills/ai-c2-detection/skill.md +30 -1
  47. package/skills/ai-risk-management/skill.md +3 -0
  48. package/skills/api-security/skill.md +3 -0
  49. package/skills/attack-surface-pentest/skill.md +3 -0
  50. package/skills/cloud-security/skill.md +3 -0
  51. package/skills/compliance-theater/skill.md +6 -0
  52. package/skills/container-runtime-security/skill.md +3 -0
  53. package/skills/coordinated-vuln-disclosure/skill.md +8 -1
  54. package/skills/defensive-countermeasure-mapping/skill.md +1 -1
  55. package/skills/dlp-gap-analysis/skill.md +3 -0
  56. package/skills/email-security-anti-phishing/skill.md +9 -1
  57. package/skills/exploit-scoring/skill.md +6 -0
  58. package/skills/identity-assurance/skill.md +6 -1
  59. package/skills/incident-response-playbook/skill.md +8 -2
  60. package/skills/kernel-lpe-triage/skill.md +24 -4
  61. package/skills/mcp-agent-trust/skill.md +28 -1
  62. package/skills/mlops-security/skill.md +3 -0
  63. package/skills/ot-ics-security/skill.md +3 -0
  64. package/skills/policy-exception-gen/skill.md +6 -0
  65. package/skills/rag-pipeline-security/skill.md +30 -1
  66. package/skills/researcher/skill.md +6 -0
  67. package/skills/sector-energy/skill.md +3 -0
  68. package/skills/sector-federal-government/skill.md +3 -0
  69. package/skills/sector-financial/skill.md +3 -0
  70. package/skills/sector-healthcare/skill.md +3 -0
  71. package/skills/security-maturity-tiers/skill.md +25 -1
  72. package/skills/skill-update-loop/skill.md +38 -0
  73. package/skills/supply-chain-integrity/skill.md +3 -0
  74. package/skills/threat-model-currency/skill.md +4 -0
  75. package/skills/threat-modeling-methodology/skill.md +3 -0
  76. package/skills/webapp-security/skill.md +3 -0
  77. package/skills/zeroday-gap-learn/skill.md +6 -0
package/bin/exceptd.js CHANGED
@@ -378,27 +378,29 @@ function main() {
378
378
  process.exit(0);
379
379
  }
380
380
 
381
+ // v0.12.8: emit the deprecation banner BEFORE branching on PLAYBOOK_VERBS
382
+ // so that legacy aliases routed through STANDALONE_VERBS or the orchestrator
383
+ // (scan, dispatch, currency, verify, validate-cves, validate-rfcs,
384
+ // watchlist, prefetch, build-indexes) also surface the rename.
385
+ // Previously the banner only fired for PLAYBOOK_VERBS-resident aliases
386
+ // (plan, govern, direct, look, ingest, reattest, list-attestations).
387
+ if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
388
+ const ver = readPkgVersion();
389
+ const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
390
+ process.stderr.write(
391
+ `[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
392
+ (haveBrief
393
+ ? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
394
+ : `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
395
+ `Legacy verbs remain functional through this release; they will be removed in v0.13. ` +
396
+ `Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
397
+ );
398
+ process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
399
+ }
400
+
381
401
  // Seven-phase playbook verbs run in-process — they emit JSON to stdout
382
402
  // rather than dispatch to a script.
383
403
  if (PLAYBOOK_VERBS.has(cmd)) {
384
- // One-time deprecation banner per process when a legacy verb is invoked.
385
- if (LEGACY_VERB_REPLACEMENTS[cmd] && !process.env.EXCEPTD_DEPRECATION_SHOWN) {
386
- // Mention the installed version explicitly so an operator on v0.10.x
387
- // who reads "Prefer brief..." doesn't go looking for a verb that
388
- // doesn't exist in their install. v0.11.0+ has the replacement; v0.10.x
389
- // users see this with the explicit "upgrade to v0.11.0 first" note.
390
- const ver = readPkgVersion();
391
- const haveBrief = ver !== "unknown" && ver.match(/^(\d+)\.(\d+)/) && (parseInt(RegExp.$1, 10) > 0 || parseInt(RegExp.$2, 10) >= 11);
392
- process.stderr.write(
393
- `[exceptd] DEPRECATION: \`${cmd}\` is a v0.10.x verb. ` +
394
- (haveBrief
395
- ? `Prefer \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (available in this install, v${ver}). `
396
- : `Upgrade to v0.11.0+ then use \`${LEGACY_VERB_REPLACEMENTS[cmd]}\` (currently installed: v${ver}). `) +
397
- `Legacy verbs remain functional through this release; they will be removed in v0.13. ` +
398
- `Suppress: export EXCEPTD_DEPRECATION_SHOWN=1.\n`
399
- );
400
- process.env.EXCEPTD_DEPRECATION_SHOWN = "1";
401
- }
402
404
  dispatchPlaybook(cmd, rest);
403
405
  return;
404
406
  }
@@ -579,7 +581,12 @@ function dispatchPlaybook(cmd, argv) {
579
581
  bool: ["pretty", "air-gap", "force-stale", "all", "flat", "directives",
580
582
  "ci", "latest", "diff-from-latest", "explain", "signal-list", "ack",
581
583
  "force-overwrite", "no-stream", "block-on-jurisdiction-clock",
582
- "json-stdout-only", "fix", "human", "json", "strict-preconditions"],
584
+ "json-stdout-only", "fix", "human", "json", "strict-preconditions",
585
+ // v0.12.9: doctor --shipped-tarball runs the verify-shipped-tarball
586
+ // gate alongside --signatures. doctor --registry-check + --signatures
587
+ // were already accepted; explicit registration removes the silent
588
+ // "unknown bool flag" surface in parseArgs.
589
+ "shipped-tarball", "registry-check", "signatures", "currency", "cves", "rfcs"],
583
590
  multi: ["playbook", "format"],
584
591
  });
585
592
  // v0.11.2 bug #60: flip defaults to human-readable. JSON via explicit --json
@@ -701,17 +708,62 @@ function buildSkillToPlaybookHint(runner, wanted) {
701
708
  if (matches.length > 0) {
702
709
  return `That is a SKILL (read-only knowledge unit), not a PLAYBOOK (executable). Skill "${wanted}" is loaded by playbook${matches.length === 1 ? "" : "s"}: ${matches.join(", ")}. ` +
703
710
  `To execute: \`exceptd run ${matches[0]}\`. To read the skill: \`exceptd skill ${wanted}\`. ` +
704
- `Tip: \`exceptd plan\` lists all 13 playbooks; \`exceptd watchlist\` lists skills.`;
711
+ `Tip: \`exceptd brief --all\` lists all 13 playbooks; \`exceptd watch\` lists skills.`;
705
712
  }
706
713
  // No matching skill either — provide nearest-playbook suggestions.
707
- const near = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
714
+ // v0.12.9 (P3 #9 from production smoke): substring fallback first (cheap),
715
+ // then edit-distance for typos that don't substring-match (`secrt`,
716
+ // `kernl`, `cret-stores`). Without the second pass `run secrt` returned
717
+ // the generic "13 playbooks" message even though `secrets` is one edit
718
+ // away.
719
+ const subMatches = ids.filter(id => id.includes(wanted) || wanted.includes(id)).slice(0, 3);
720
+ const fuzzyMatches = subMatches.length === 0 ? nearestByEditDistance(wanted, ids, 2).slice(0, 3) : [];
721
+ const near = subMatches.length ? subMatches : fuzzyMatches;
708
722
  if (near.length > 0) {
709
- return `Did you mean: ${near.join(", ")}? Run \`exceptd plan\` for the full list.`;
723
+ return `Did you mean: ${near.join(", ")}? Run \`exceptd brief --all\` for the full list.`;
710
724
  }
711
- return `Run \`exceptd plan\` to list the 13 playbooks.`;
725
+ return `Run \`exceptd brief --all\` to list the 13 playbooks.`;
712
726
  } catch { return null; }
713
727
  }
714
728
 
729
+ /**
730
+ * Cheap Levenshtein distance, used to surface "Did you mean X?" suggestions
731
+ * for misspelled playbook ids in the `run <typo>` error path. Returns ids
732
+ * whose distance from `wanted` is ≤ `maxDistance`, sorted by closest first.
733
+ * Bounded by the candidate set size (13 playbooks), so the O(n*m) cost is
734
+ * negligible.
735
+ */
736
+ function nearestByEditDistance(wanted, ids, maxDistance) {
737
+ if (!wanted || !Array.isArray(ids)) return [];
738
+ const w = String(wanted).toLowerCase();
739
+ const scored = [];
740
+ for (const id of ids) {
741
+ const d = editDistance(w, id.toLowerCase());
742
+ if (d <= maxDistance) scored.push({ id, d });
743
+ }
744
+ scored.sort((a, b) => a.d - b.d);
745
+ return scored.map(s => s.id);
746
+ }
747
+
748
+ function editDistance(a, b) {
749
+ if (a === b) return 0;
750
+ if (a.length === 0) return b.length;
751
+ if (b.length === 0) return a.length;
752
+ const prev = new Array(b.length + 1);
753
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
754
+ for (let i = 1; i <= a.length; i++) {
755
+ let cur = i;
756
+ for (let j = 1; j <= b.length; j++) {
757
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
758
+ const next = Math.min(prev[j] + 1, cur + 1, prev[j - 1] + cost);
759
+ prev[j - 1] = cur;
760
+ cur = next;
761
+ }
762
+ prev[b.length] = cur;
763
+ }
764
+ return prev[b.length];
765
+ }
766
+
715
767
  function printPlaybookVerbHelp(verb) {
716
768
  const cmds = {
717
769
  plan: `plan — list playbooks + directives, grouped by scope.
@@ -798,10 +850,24 @@ Flags:
798
850
  (code 2) when phases.detect.classification === 'detected'
799
851
  OR phases.analyze.rwep.adjusted >= rwep_threshold.escalate.
800
852
  Logs PASS/FAIL reason to stderr.
801
- --session-id <id> Reuse a specific session ID.
853
+ --upstream-check (v0.11.14) Opt-in: query npm registry for the latest
854
+ published @blamejs/exceptd-skills version before
855
+ detect. Warns to stderr (no exit-code change) when
856
+ the local install is behind, so an operator using a
857
+ stale catalog finds out before the run completes.
858
+ --strict-preconditions Escalate warn-level precondition failures to halt.
859
+ Without this flag, only on_fail=halt preconditions
860
+ block; warn-level surface in stderr but the run
861
+ proceeds. With it, any precondition_check returning
862
+ false fails the run and exits non-zero.
863
+ --session-id <id> Reuse a specific session ID. Collisions refused
864
+ unless --force-overwrite is also passed.
865
+ --force-overwrite Override the session-id collision refusal.
802
866
  --session-key <hex> HMAC sign the evidence_package with this key.
867
+ Output carries an 'hmac' field the verifier can check.
803
868
  --force-stale Override the threat_currency_score < 50 hard-block.
804
- --air-gap Honor air_gap_alternative paths.
869
+ --air-gap Honor air_gap_alternative paths in look.artifacts[]
870
+ and skip the network-touching collection variants.
805
871
  --pretty Indented JSON output.
806
872
 
807
873
  Attestation is persisted to .exceptd/attestations/<session_id>/ on every
@@ -835,12 +901,22 @@ newest-first, with truncated evidence_hash + capture timestamp + file path.`,
835
901
 
836
902
  Subverbs:
837
903
  attest show <sid> Emit the full (unredacted) attestation.
904
+ attest list Inventory every prior attestation under
905
+ ~/.exceptd/attestations/ (or EXCEPTD_HOME when set).
906
+ Filter with --playbook <id> or --since <ISO>. Newest
907
+ first; truncated evidence_hash + capture timestamp +
908
+ path per entry.
838
909
  attest export <sid> Emit redacted JSON suitable for audit submission.
839
910
  Strips raw artifact values; preserves evidence_hash,
840
911
  signature, classification, RWEP, remediation choice.
841
- --format csaf wraps the export in a CSAF envelope.
912
+ --format <csaf|sarif|openvex> wraps the export in the
913
+ named envelope (default: redacted JSON).
842
914
  attest verify <sid> Verify .sig sidecar against keys/public.pem.
843
915
  Reports tamper status per attestation file.
916
+ attest diff <sid> Diff <sid> against the most-recent prior attestation
917
+ for the same playbook, or against --against <other-sid>
918
+ for an explicit pair. Reports unchanged | drifted |
919
+ resolved per evidence_hash + classification deltas.
844
920
 
845
921
  All subverbs honor --pretty for indented JSON output.`,
846
922
  discover: `discover — context-aware playbook recommender (v0.11.0).
@@ -867,7 +943,20 @@ Subchecks:
867
943
  --currency Skill currency report (last_threat_review).
868
944
  --cves CVE catalog validation (offline view).
869
945
  --rfcs RFC catalog validation (offline view).
870
- (no flag) All four, plus signing-status (private key presence).
946
+ --registry-check (v0.11.14) Opt-in: query the npm registry for the
947
+ latest published version + days-since-publish.
948
+ Surfaces under checks.registry.{local_version,
949
+ published_version, same, behind, days_since_latest_publish}.
950
+ Off by default — keeps doctor offline-clean unless
951
+ asked.
952
+ --fix (v0.12.5) Attempt to auto-remediate detected gaps.
953
+ Currently scoped to: regenerate the local Ed25519
954
+ private key when keys/public.pem exists but
955
+ .keys/private.pem is absent. Does NOT modify any
956
+ file outside .keys/.
957
+ (no flag) All four subchecks above (sans --registry-check
958
+ unless explicitly requested), plus signing-status
959
+ (private key presence under .keys/).
871
960
 
872
961
  Flags:
873
962
  --json Emit JSON (default is human-readable text).
@@ -914,6 +1003,9 @@ exit-code contract designed for one-line .github/workflows entries.
914
1003
  Flags:
915
1004
  --all Run every playbook.
916
1005
  --scope <type> Filter: system | code | service | cross-cutting.
1006
+ --required <ids> Comma-separated playbook ids that MUST run, even if
1007
+ scope-detection would exclude them. Fails if a
1008
+ required id is unknown.
917
1009
  (no flag) Auto-detect scopes from cwd (same logic as run).
918
1010
  --evidence <file> Submission bundle (multi-playbook shape).
919
1011
  --evidence-dir <dir> Read <playbook-id>.json files from a directory.
@@ -921,11 +1013,77 @@ Flags:
921
1013
  --block-on-jurisdiction-clock
922
1014
  Fail when any close.notification_actions started a
923
1015
  regulatory clock (GDPR 72h, HIPAA breach, etc.).
924
- --pretty Indented JSON output.
1016
+ --format <fmt> Output shape. Supported: json (default, single-line),
1017
+ summary (5-field digest), markdown (human digest).
1018
+ Bundles (csaf-2.0/sarif/openvex) live on per-run
1019
+ attestations, not the aggregate ci verdict.
1020
+ --json Force single-line JSON (overrides any TTY heuristics).
1021
+ --pretty Indented JSON output (implies --json).
1022
+
1023
+ Exit codes:
1024
+ 0 PASS All scoped playbooks ran and verdict is clean.
1025
+ 1 Framework error Runner threw, unreadable evidence, etc.
1026
+ 2 FAIL (detected) At least one playbook returned
1027
+ classification=detected, OR rwep ≥ escalate, OR
1028
+ --max-rwep cap exceeded.
1029
+ 3 Ran-but-no-evidence Every result was inconclusive AND no evidence was
1030
+ submitted (visibility gap — CI should fail loud).
1031
+ 4 Blocked Result returned ok:false (preflight halt, missing
1032
+ preconditions with on_fail=halt, etc.) OR
1033
+ --block-on-jurisdiction-clock fired.
925
1034
 
926
- Exit codes: 0 PASS, 2 FAIL (detected | rwep ≥ cap | clock started w/ block flag).
927
1035
  Output: verb, session_id, playbooks_run, summary{total, detected,
928
- max_rwep_observed, jurisdiction_clocks_started, verdict}, results[].`,
1036
+ max_rwep_observed, jurisdiction_clocks_started, verdict, fail_reasons[]},
1037
+ results[].`,
1038
+ brief: `brief [playbook] — unified info doc (v0.11.0).
1039
+
1040
+ Collapses the three info-only phases plan + govern + direct + look into a
1041
+ single document. Phases 1-3 of the seven-phase contract are entirely
1042
+ informational; brief reads them in one CLI invocation instead of three.
1043
+
1044
+ Modes:
1045
+ brief Auto-detect playbooks for the cwd. Returns a list.
1046
+ brief <playbook> Single-playbook brief with jurisdiction obligations
1047
+ + threat context + preconditions + artifacts +
1048
+ indicators.
1049
+ brief --all Every shipped playbook.
1050
+ brief --scope <type> Filter: system | code | service | cross-cutting.
1051
+ brief <pb> --phase <p> Emit only the named phase (govern | direct | look).
1052
+ Compat for legacy callers.
1053
+
1054
+ Flags:
1055
+ --directives Expand directive metadata per playbook.
1056
+ --pretty Indented JSON output.
1057
+ --json Force single-line JSON.
1058
+
1059
+ Output (single-playbook): playbook_id, directives[], jurisdiction_obligations[],
1060
+ threat_context, preconditions[], artifacts[], indicators[].`,
1061
+ lint: `lint <playbook> <evidence-file> — pre-flight check submission shape.
1062
+
1063
+ Validates the submission JSON against the playbook's expected indicators /
1064
+ preconditions / artifacts WITHOUT executing detect/analyze/validate/close.
1065
+ Lets the AI iterate on its evidence before going through phases 4-7.
1066
+
1067
+ Args / flags:
1068
+ <playbook> Playbook id. Required.
1069
+ <evidence-file> Submission JSON path. Required.
1070
+ --pretty Indented JSON output.
1071
+
1072
+ Output categories: ok, missing_required, missing_required_artifact,
1073
+ unknown_keys, type_mismatch, suggestions.`,
1074
+ "verify-attestation": `verify-attestation <session-id> — alias for \`attest verify\`.
1075
+
1076
+ See \`exceptd attest --help\` for the full attest verb. This alias matches
1077
+ the historical verify-attestation entry-point name used by some downstream
1078
+ consumers.
1079
+
1080
+ Flags: --pretty.`,
1081
+ "run-all": `run-all — alias for \`run --all\`.
1082
+
1083
+ Identical exit-code and output contract as \`run --all\`. Maintained for
1084
+ operators who script the verb form rather than the flag.
1085
+
1086
+ See \`exceptd run --help\` for the full flag list.`,
929
1087
  };
930
1088
  process.stdout.write((cmds[verb] || `${verb} — no per-verb help available; see \`exceptd help\` for the full list.`) + "\n");
931
1089
  }
@@ -1080,6 +1238,18 @@ function cmdBrief(runner, args, runOpts, pretty) {
1080
1238
  const playbookId = args._[0];
1081
1239
  const onlyPhase = args.phase || null;
1082
1240
 
1241
+ // v0.12.9 (P2 #7 from production smoke): refuse garbage values to --phase.
1242
+ // Pre-v0.12.9 `brief secrets --phase foo` silently accepted any string and
1243
+ // emitted the full brief — operators got no signal the flag was misused.
1244
+ // The legacy-compat surface is exactly the three v0.10.x verb names
1245
+ // (govern | direct | look); anything else is a typo or a misunderstanding.
1246
+ if (onlyPhase != null) {
1247
+ const ACCEPTED_PHASES = ["govern", "direct", "look"];
1248
+ if (!ACCEPTED_PHASES.includes(onlyPhase)) {
1249
+ return emitError(`brief: --phase "${onlyPhase}" not in accepted set ${JSON.stringify(ACCEPTED_PHASES)}.`, { verb: "brief", provided: onlyPhase }, pretty);
1250
+ }
1251
+ }
1252
+
1083
1253
  if (!playbookId || args.all) {
1084
1254
  // Multi-playbook brief (replaces `plan`). Reuses cmdPlan output shape.
1085
1255
  return cmdPlan(runner, args, runOpts, pretty);
@@ -1562,13 +1732,20 @@ function cmdRun(runner, args, runOpts, pretty) {
1562
1732
 
1563
1733
  emit(result, pretty);
1564
1734
 
1735
+ // v0.12.8: use process.exitCode + return instead of process.exit() so
1736
+ // buffered async stdout (which `emit` writes to) is allowed to drain
1737
+ // before the event loop ends. v0.11.10 (#100) is the canonical class:
1738
+ // process.exit(N) immediately after a stdout write can truncate output
1739
+ // under piped consumers (CI runners, jq, test harnesses).
1565
1740
  if (classification === "detected") {
1566
1741
  process.stderr.write(`[exceptd run --ci] FAIL: classification=detected rwep=${adjusted} threshold=${threshold}\n`);
1567
- process.exit(2);
1742
+ process.exitCode = 2;
1743
+ return;
1568
1744
  }
1569
1745
  if (classification === "inconclusive" && escalate) {
1570
1746
  process.stderr.write(`[exceptd run --ci] FAIL: classification=inconclusive AND rwep=${adjusted} >= threshold=${threshold}\n`);
1571
- process.exit(2);
1747
+ process.exitCode = 2;
1748
+ return;
1572
1749
  }
1573
1750
  if (classification === "inconclusive") {
1574
1751
  process.stderr.write(`[exceptd run --ci] PASS+WARN: classification=inconclusive rwep=${adjusted} < threshold=${threshold} (visibility gap)\n`);
@@ -1667,10 +1844,19 @@ function cmdRun(runner, args, runOpts, pretty) {
1667
1844
  const verdictIcon = cls === "detected" ? "[!! DETECTED]" : cls === "inconclusive" ? "[i INCONCLUSIVE]" : "[ok]";
1668
1845
  lines.push(`\n${verdictIcon} classification=${cls} RWEP ${adj}/${top}${adj !== base ? ` (Δ${adj - base} from operator evidence)` : " (catalog baseline)"} blast_radius=${obj.phases?.analyze?.blast_radius_score ?? "n/a"}/5`);
1669
1846
  const cves = obj.phases?.analyze?.matched_cves || [];
1847
+ const baseline = obj.phases?.analyze?.catalog_baseline_cves || [];
1670
1848
  if (cves.length) {
1671
1849
  lines.push(`\nMatched CVEs (${cves.length}):`);
1672
- for (const c of cves.slice(0, 6)) lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev} ${c.active_exploitation || ""}`);
1850
+ for (const c of cves.slice(0, 6)) {
1851
+ const via = Array.isArray(c.correlated_via) && c.correlated_via.length ? ` via ${c.correlated_via[0]}${c.correlated_via.length > 1 ? ` (+${c.correlated_via.length - 1})` : ""}` : "";
1852
+ lines.push(` ${c.cve_id} RWEP ${c.rwep} KEV=${c.cisa_kev} ${c.active_exploitation || ""}${via}`);
1853
+ }
1673
1854
  if (cves.length > 6) lines.push(` … ${cves.length - 6} more`);
1855
+ } else if (baseline.length) {
1856
+ // No evidence correlated to any CVE — clarify rather than implying the
1857
+ // operator is affected by the catalog enumeration. Pre-fix output read
1858
+ // like a hit list; explicit zero + scan-coverage callout fixes that.
1859
+ lines.push(`\nNo CVEs correlated to your evidence. Playbook catalog (informational): ${baseline.length} CVE(s) this playbook scans for.`);
1674
1860
  }
1675
1861
  const indicators = obj.phases?.detect?.indicators || [];
1676
1862
  const hits = indicators.filter(i => i.verdict === "hit");
@@ -1693,7 +1879,16 @@ function cmdRun(runner, args, runOpts, pretty) {
1693
1879
  const issues = obj.preflight_issues || [];
1694
1880
  if (issues.length) {
1695
1881
  lines.push(`\nPreflight warnings (${issues.length}):`);
1696
- for (const i of issues) lines.push(` [${i.on_fail}] ${i.id}: ${i.check || ""}`);
1882
+ // v0.12.9 (P3 #12 from production smoke): handle preconditions without
1883
+ // an `on_fail` field (precondition.check was satisfied trivially or the
1884
+ // playbook omits the field). Pre-v0.12.9 these rendered as `[undefined]
1885
+ // <id>:`. Now: omit the bracket when on_fail is absent, and fall back
1886
+ // to the description if `check` is missing too.
1887
+ for (const i of issues) {
1888
+ const tag = i.on_fail ? `[${i.on_fail}] ` : "";
1889
+ const detail = i.check || i.description || i.reason || "(no detail)";
1890
+ lines.push(` ${tag}${i.id}: ${detail}`);
1891
+ }
1697
1892
  }
1698
1893
  lines.push(`\nFull structured result: --json (or --pretty for indented).`);
1699
1894
  return lines.join("\n");
@@ -1798,9 +1993,10 @@ function cmdRunMulti(runner, ids, args, runOpts, pretty, meta) {
1798
1993
  // v0.11.9 (#100): cmdRunMulti exits non-zero when any individual run
1799
1994
  // returned ok:false. Pre-0.11.9 the aggregate result had {ok:false} in
1800
1995
  // the body but exit code stayed 0 — CI gates couldn't distinguish "ran
1801
- // clean" from "blocked." Now matches cmdRun's single-playbook contract.
1996
+ // clean" from "blocked." v0.12.8: use exitCode (not process.exit()) so
1997
+ // the aggregate JSON emitted above is allowed to fully drain.
1802
1998
  const anyBlocked = results.some(r => r.ok === false);
1803
- if (anyBlocked) process.exit(1);
1999
+ if (anyBlocked) { process.exitCode = 1; return; }
1804
2000
  }
1805
2001
 
1806
2002
  function cmdIngest(runner, args, runOpts, pretty) {
@@ -1835,28 +2031,38 @@ function cmdIngest(runner, args, runOpts, pretty) {
1835
2031
 
1836
2032
  const result = runner.run(playbookId, directiveId, cleanedSubmission, runOpts);
1837
2033
 
2034
+ // v0.12.8: route ingest's attestation persistence through persistAttestation
2035
+ // — the same path cmdRun + cmdRunMulti use — so the session-id collision
2036
+ // refusal AND the Ed25519 sidecar signing both apply. Pre-v0.12.8 ingest
2037
+ // had its own inline writeFileSync with neither check, meaning two ingest
2038
+ // calls with the same session-id silently clobbered the audit trail and no
2039
+ // .sig sidecar was written.
1838
2040
  if (result && result.ok && result.session_id) {
1839
- try {
1840
- const dir = path.join(resolveAttestationRoot(runOpts), result.session_id);
1841
- fs.mkdirSync(dir, { recursive: true });
1842
- fs.writeFileSync(
1843
- path.join(dir, "attestation.json"),
1844
- JSON.stringify({
1845
- session_id: result.session_id,
1846
- playbook_id: result.playbook_id,
1847
- directive_id: result.directive_id,
1848
- evidence_hash: result.evidence_hash,
1849
- submission: cleanedSubmission,
1850
- run_opts: { airGap: runOpts.airGap, forceStale: runOpts.forceStale, mode: runOpts.mode },
1851
- captured_at: new Date().toISOString(),
1852
- }, null, 2)
1853
- );
1854
- } catch { /* non-fatal */ }
2041
+ const persisted = persistAttestation({
2042
+ sessionId: result.session_id,
2043
+ playbookId: result.playbook_id,
2044
+ directiveId: result.directive_id,
2045
+ evidenceHash: result.evidence_hash,
2046
+ operator: runOpts.operator,
2047
+ operatorConsent: runOpts.operator_consent,
2048
+ submission: cleanedSubmission,
2049
+ runOpts,
2050
+ forceOverwrite: !!args["force-overwrite"],
2051
+ filename: "attestation.json",
2052
+ });
2053
+ if (!persisted.ok) {
2054
+ // Surface the collision; do not silently clobber.
2055
+ return emitError(persisted.error, { session_id: result.session_id, existing_path: persisted.existingPath }, pretty);
2056
+ }
2057
+ if (persisted.prior_session_id) {
2058
+ result.attestation_persist = { ok: true, prior_session_id: persisted.prior_session_id, overwrote_at: persisted.overwrote_at };
2059
+ }
1855
2060
  }
1856
2061
 
1857
2062
  if (result && result.ok === false) {
1858
2063
  process.stderr.write((pretty ? JSON.stringify(result, null, 2) : JSON.stringify(result)) + "\n");
1859
- process.exit(1);
2064
+ process.exitCode = 1;
2065
+ return;
1860
2066
  }
1861
2067
  emit(result, pretty);
1862
2068
  }
@@ -1981,6 +2187,15 @@ function persistAttestation(args) {
1981
2187
  function maybeSignAttestation(filePath) {
1982
2188
  const crypto = require("crypto");
1983
2189
  const sigPath = filePath + ".sig";
2190
+ // v0.12.9 (P2 #3 from production smoke + codex P1 PR #4 review): keep the
2191
+ // sign key aligned with the VERIFY key. `attest verify` checks signatures
2192
+ // against PKG_ROOT/keys/public.pem; if we sign with cwd/.keys/private.pem
2193
+ // (e.g. the maintainer's repo-local keypair) the resulting `.sig` will
2194
+ // verify INVALID and report a false tamper signal on every freshly-written
2195
+ // attestation. PKG_ROOT-only resolution is the right answer; the original
2196
+ // smoke report's "doctor finds key, run does not" gap is fixed in `doctor`
2197
+ // (reporting only PKG_ROOT now), not by making `run` follow a cwd key the
2198
+ // verifier doesn't trust.
1984
2199
  const privKeyPath = path.join(PKG_ROOT, ".keys", "private.pem");
1985
2200
  const content = fs.readFileSync(filePath, "utf8");
1986
2201
  // One-time-per-process unsigned warning so cron jobs don't spam stderr.
@@ -2714,6 +2929,46 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2714
2929
  ...(ok ? {} : { exit_code: res.status, raw: text.slice(0, 500) }),
2715
2930
  };
2716
2931
  if (!ok) issues.push("signatures");
2932
+
2933
+ // v0.12.9 (P3 #10 from production smoke): also run the shipped-tarball
2934
+ // round-trip gate (sign + pack + extract + verify) when the operator
2935
+ // opts in via --shipped-tarball. This is the v0.12.3 verify-as-shipped
2936
+ // gate that closed the v0.11.x → v0.12.4 signature regression class
2937
+ // (source-tree verify passed; shipped-tarball verify failed). It's
2938
+ // opt-in because npm pack adds ~5-10s and creates tempdir churn —
2939
+ // routine `doctor --signatures` stays fast.
2940
+ if (args["shipped-tarball"]) {
2941
+ try {
2942
+ const tarballScript = path.join(PKG_ROOT, "scripts", "verify-shipped-tarball.js");
2943
+ if (fs.existsSync(tarballScript)) {
2944
+ const tRes = spawnSync(process.execPath, [tarballScript], {
2945
+ encoding: "utf8",
2946
+ cwd: PKG_ROOT,
2947
+ timeout: 120000,
2948
+ });
2949
+ const tText = (tRes.stdout || "") + (tRes.stderr || "");
2950
+ const tOk = tRes.status === 0;
2951
+ const tMatch = tText.match(/(\d+)\/(\d+)\s+pass,\s+(\d+)\s+fail/i);
2952
+ checks.signatures.shipped_tarball = {
2953
+ ok: tOk,
2954
+ skills_passed: tMatch ? Number(tMatch[1]) : null,
2955
+ skills_total: tMatch ? Number(tMatch[2]) : null,
2956
+ skills_failed: tMatch ? Number(tMatch[3]) : null,
2957
+ ...(tOk ? {} : { exit_code: tRes.status, raw: tText.slice(-500) }),
2958
+ };
2959
+ if (!tOk) issues.push("signatures.shipped_tarball");
2960
+ } else {
2961
+ checks.signatures.shipped_tarball = {
2962
+ ok: null,
2963
+ skipped: true,
2964
+ reason: "scripts/verify-shipped-tarball.js not present (likely an installed package, not a source checkout). The tarball-verify gate runs at release time; routine integrity is covered by `--signatures`.",
2965
+ };
2966
+ }
2967
+ } catch (e) {
2968
+ checks.signatures.shipped_tarball = { ok: false, error: e.message };
2969
+ issues.push("signatures.shipped_tarball");
2970
+ }
2971
+ }
2717
2972
  } catch (e) {
2718
2973
  checks.signatures = { ok: false, error: e.message };
2719
2974
  issues.push("signatures");
@@ -2815,9 +3070,14 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2815
3070
 
2816
3071
  if (runSigning) {
2817
3072
  try {
2818
- const keyPath = path.join(process.cwd(), ".keys", "private.pem");
2819
- const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
2820
- const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
3073
+ // v0.12.9 codex P1 (PR #4): report only PKG_ROOT — that's the path
3074
+ // maybeSignAttestation() and `attest verify` actually use. Pre-v0.12.9
3075
+ // doctor also reported cwd-resident keys as present, which gave a
3076
+ // false-positive "signing enabled" signal when the operator's cwd
3077
+ // key was misaligned with the PKG_ROOT-resident public key used at
3078
+ // verify time.
3079
+ const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3080
+ const present = fs.existsSync(keyPath);
2821
3081
  // Bug #61 (v0.11.2): signing-status missing key is a real WARNING. The
2822
3082
  // attestation pipeline writes unsigned files when this is absent, which
2823
3083
  // operators reading the attestation later cannot verify for authenticity.
@@ -2902,10 +3162,9 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2902
3162
  });
2903
3163
  if (r.status === 0) {
2904
3164
  // Re-verify the private key is now present so the JSON output reflects
2905
- // the fix.
2906
- const keyPath = path.join(process.cwd(), ".keys", "private.pem");
2907
- const fallback = path.join(PKG_ROOT, ".keys", "private.pem");
2908
- const present = fs.existsSync(keyPath) || fs.existsSync(fallback);
3165
+ // the fix. v0.12.9 codex P1: PKG_ROOT-only (sign + verify use this path).
3166
+ const keyPath = path.join(PKG_ROOT, ".keys", "private.pem");
3167
+ const present = fs.existsSync(keyPath);
2909
3168
  checks.signing = { ok: present, severity: present ? "info" : "warn", private_key_present: present, can_sign_attestations: present };
2910
3169
  out.checks = checks;
2911
3170
  out.summary.fix_applied = "ed25519_keypair_generated";
@@ -2954,6 +3213,35 @@ function cmdDoctor(runner, args, runOpts, pretty) {
2954
3213
  ? `RFC catalog: ${c.total ?? "?"} entries, drift ${c.drift ?? 0}`
2955
3214
  : `RFC catalog FAILED (exit=${c.exit_code ?? "?"})`
2956
3215
  );
3216
+ // v0.12.9 (P3 #11 from production smoke): render registry-check in text mode.
3217
+ // Pre-v0.12.9 --registry-check populated checks.registry only in the JSON
3218
+ // output; operators in text mode had to add --json to see if the flag did
3219
+ // anything. Now the line surfaces in the human checklist.
3220
+ mark(checks.registry, c => {
3221
+ if (c.skipped) return `npm registry check: skipped (${c.reason || "unknown reason"})`;
3222
+ if (!c.ok && !c.same && c.behind) {
3223
+ const days = c.days_since_latest_publish != null ? `${c.days_since_latest_publish}d` : "?";
3224
+ return `npm registry: local v${c.local_version ?? "?"} BEHIND published v${c.published_version ?? "?"} (${days})`;
3225
+ }
3226
+ if (c.same) {
3227
+ return `npm registry: local v${c.local_version ?? "?"} == published v${c.published_version ?? "?"} (current)`;
3228
+ }
3229
+ if (c.ahead) {
3230
+ return `npm registry: local v${c.local_version ?? "?"} AHEAD of published v${c.published_version ?? "?"} (unreleased / dev install)`;
3231
+ }
3232
+ return `npm registry: check returned no comparison (raw exit=${c.exit_code ?? "?"})`;
3233
+ });
3234
+ // v0.12.9 (P3 #10): surface shipped_tarball sub-check when --shipped-tarball was used.
3235
+ if (checks.signatures?.shipped_tarball) {
3236
+ const st = checks.signatures.shipped_tarball;
3237
+ if (st.skipped) {
3238
+ lines.push(` [info] shipped tarball verify: skipped (${st.reason})`);
3239
+ } else if (st.ok) {
3240
+ lines.push(` [ok] shipped tarball verify: ${st.skills_passed ?? "?"}/${st.skills_total ?? "?"} skills pass on extracted tarball`);
3241
+ } else {
3242
+ lines.push(` [!!] shipped tarball verify FAILED: ${st.skills_failed ?? "?"}/${st.skills_total ?? "?"} skills fail (exit=${st.exit_code ?? "?"})`);
3243
+ }
3244
+ }
2957
3245
  if (checks.signing) {
2958
3246
  if (checks.signing.private_key_present) {
2959
3247
  lines.push(` [ok] attestation signing: private key present (.keys/private.pem)`);
@@ -3196,13 +3484,22 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3196
3484
  let handled = false;
3197
3485
  let buf = "";
3198
3486
 
3487
+ // v0.12.8: every writeLine() in this handler writes to stdout. Replacing
3488
+ // process.exit() with exitCode + closing stdin lets the JSONL frames
3489
+ // drain before the event loop ends. `handled` plus process.stdin.pause()
3490
+ // prevents further callbacks from re-entering the handler.
3491
+ const finish = (code) => {
3492
+ process.exitCode = code;
3493
+ try { process.stdin.pause(); } catch { /* non-fatal */ }
3494
+ };
3199
3495
  const handleLine = (line) => {
3200
3496
  if (handled) return;
3201
3497
  let parsed;
3202
3498
  try { parsed = JSON.parse(line); }
3203
3499
  catch (e) {
3500
+ handled = true;
3204
3501
  writeLine({ event: "error", reason: `invalid JSON on stdin: ${e.message}`, line_preview: line.slice(0, 120) });
3205
- process.exit(1);
3502
+ return finish(1);
3206
3503
  }
3207
3504
  if (!parsed || parsed.event !== "evidence" || !parsed.payload) {
3208
3505
  // Ignore non-evidence chatter so the host AI can interleave its own
@@ -3216,18 +3513,18 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3216
3513
  result = runner.run(playbookId, directiveId, submission, runOpts);
3217
3514
  } catch (e) {
3218
3515
  writeLine({ event: "error", reason: `runner threw: ${e.message}` });
3219
- process.exit(1);
3516
+ return finish(1);
3220
3517
  }
3221
3518
  if (!result || result.ok === false) {
3222
3519
  writeLine({ event: "error", reason: result?.reason || "runner returned ok:false", result });
3223
- process.exit(1);
3520
+ return finish(1);
3224
3521
  }
3225
3522
  writeLine({ phase: "detect", ...result.phases?.detect });
3226
3523
  writeLine({ phase: "analyze", ...result.phases?.analyze });
3227
3524
  writeLine({ phase: "validate", ...result.phases?.validate });
3228
3525
  writeLine({ phase: "close", ...result.phases?.close });
3229
3526
  writeLine({ event: "done", ok: true, session_id: result.session_id, evidence_hash: result.evidence_hash });
3230
- process.exit(0);
3527
+ return finish(0);
3231
3528
  };
3232
3529
 
3233
3530
  // Handle empty/closed stdin: emit a hint then exit cleanly so AI agents
@@ -3235,7 +3532,8 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3235
3532
  // a hung process.
3236
3533
  if (process.stdin.isTTY) {
3237
3534
  writeLine({ event: "error", reason: "ai-run streaming mode requires evidence on stdin; pipe {\"event\":\"evidence\",\"payload\":{...}} or use --no-stream." });
3238
- process.exit(1);
3535
+ process.exitCode = 1;
3536
+ return;
3239
3537
  }
3240
3538
 
3241
3539
  process.stdin.on("data", (chunk) => {
@@ -3270,7 +3568,8 @@ function cmdAiRun(runner, args, runOpts, pretty) {
3270
3568
  } catch { /* fall through to error */ }
3271
3569
  }
3272
3570
  writeLine({ event: "error", reason: "stdin closed without an evidence event. Pipe `{\"event\":\"evidence\",\"payload\":{...}}` for streaming mode, or pass --no-stream + --evidence <file> for single-shot." });
3273
- process.exit(1);
3571
+ process.exitCode = 1;
3572
+ return;
3274
3573
  }
3275
3574
  });
3276
3575
 
@@ -3574,17 +3873,65 @@ function cmdCi(runner, args, runOpts, pretty) {
3574
3873
  const rwepValues = results.map(r => r.phases?.analyze?.rwep?.adjusted ?? 0);
3575
3874
  const maxRwepObserved = rwepValues.length ? Math.max(...rwepValues) : 0;
3576
3875
 
3876
+ // v0.12.9 (P1 #2 from production smoke): reconcile verdict with exit code.
3877
+ // Pre-v0.12.9 the no-evidence-all-inconclusive path emitted verdict="PASS"
3878
+ // but the process exited 3 ("ran but no evidence"). CI consumers reading
3879
+ // exit code only failed a PASS run; consumers reading verdict only passed
3880
+ // a no-data run. Now compute the verdict up-front to match the exit-code
3881
+ // matrix (BLOCKED > FAIL > NO_EVIDENCE > PASS) so both surfaces agree.
3882
+ const suppliedEvidenceForVerdict = args.evidence || args["evidence-dir"];
3883
+ const blockedCount = results.filter(r => r && r.ok === false).length;
3884
+ const inconclusiveCount = results.filter(r => r.phases?.detect?.classification === "inconclusive").length;
3885
+ const totalForVerdict = results.length;
3886
+ const noEvidenceAllInconclusive = !suppliedEvidenceForVerdict && totalForVerdict > 0 && inconclusiveCount === totalForVerdict;
3887
+ const computedVerdict = blockedCount > 0
3888
+ ? "BLOCKED"
3889
+ : fail
3890
+ ? "FAIL"
3891
+ : noEvidenceAllInconclusive
3892
+ ? "NO_EVIDENCE"
3893
+ : "PASS";
3894
+
3895
+ // v0.12.9 (P2 #8 from production smoke): roll up per-playbook framework_gap
3896
+ // mappings to the ci top-level. Phase 7 of the seven-phase contract surfaces
3897
+ // framework_gap_mapping per result; pre-v0.12.9 ci never aggregated them,
3898
+ // so operators got individual-playbook results only. Now: top-level
3899
+ // framework_gap_rollup lists each {framework, claimed_control} once with
3900
+ // the set of playbooks that flagged it — single-glance "what gaps did this
3901
+ // gate uncover across the scoped playbooks."
3902
+ const gapRollupMap = new Map();
3903
+ for (const r of results) {
3904
+ const gaps = r.phases?.analyze?.framework_gap_mapping || [];
3905
+ for (const g of gaps) {
3906
+ const key = `${g.framework || "unknown"}::${g.claimed_control || "unspecified"}`;
3907
+ const existing = gapRollupMap.get(key);
3908
+ if (existing) {
3909
+ if (!existing.playbooks.includes(r.playbook_id)) existing.playbooks.push(r.playbook_id);
3910
+ } else {
3911
+ gapRollupMap.set(key, {
3912
+ framework: g.framework || null,
3913
+ claimed_control: g.claimed_control || null,
3914
+ why_insufficient: g.why_insufficient || null,
3915
+ playbooks: [r.playbook_id],
3916
+ });
3917
+ }
3918
+ }
3919
+ }
3920
+ const frameworkGapRollup = [...gapRollupMap.values()];
3921
+
3577
3922
  const summary = {
3578
3923
  total: results.length,
3579
3924
  detected: results.filter(r => r.phases?.detect?.classification === "detected").length,
3580
- inconclusive: results.filter(r => r.phases?.detect?.classification === "inconclusive").length,
3925
+ inconclusive: inconclusiveCount,
3581
3926
  not_detected: results.filter(r => ["not_detected", "clean"].includes(r.phases?.detect?.classification)).length,
3582
- blocked: results.filter(r => r && r.ok === false).length,
3927
+ blocked: blockedCount,
3583
3928
  max_rwep_observed: maxRwepObserved,
3584
3929
  jurisdiction_clocks_started: results
3585
3930
  .flatMap(r => r.phases?.close?.notification_actions || [])
3586
3931
  .filter(n => n && n.clock_started_at != null).length,
3587
- verdict: fail ? "FAIL" : "PASS",
3932
+ framework_gap_rollup: frameworkGapRollup,
3933
+ framework_gap_count: frameworkGapRollup.length,
3934
+ verdict: computedVerdict,
3588
3935
  fail_reasons: failReasons,
3589
3936
  };
3590
3937