@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.0

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 (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -61,14 +61,16 @@ import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
61
61
  import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
62
62
  import { enrichWithEPSS } from '../src/posture/epss.js';
63
63
  import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
64
- import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
65
- import { applyFix, undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
64
+ import { applyCustomRules, runRuleTests, loadCustomRules, customRulesFreshness } from '../src/posture/custom-rules.js';
65
+ import { undoLast, undoAll, listHistory, preview as previewDiff, compactLog } from '../src/posture/fix-history.js';
66
+ import { applyVerifiedFix, confinePath } from '../src/fix/apply-fix-service.js';
66
67
  import { syncTickets } from '../src/integrations/tickets.js';
67
68
  import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
68
69
  import * as triage from '../src/posture/triage.js';
69
70
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
70
71
 
71
72
  import { stateDir, statePath } from '../src/posture/state-dir.js';
73
+ import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
72
74
  // last-scan.json integrity helpers — implementation in posture/integrity.js
73
75
  // so the MCP server tools can share verification.
74
76
  function _verifyLastScan(body, sigFile) {
@@ -103,6 +105,17 @@ Commands:
103
105
  validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
104
106
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
105
107
  reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
108
+ --expired only remove artifacts past their retention-class TTL
109
+ Every run writes a deletion-report.json proving what was planned/deleted/preserved/failed.
110
+ export --out <dir> Copy every present .agentic-security/ artifact to <dir> with a manifest (export-manifest.json,
111
+ unsigned) proving what was exported and what failed — for migration or legal-preservation purposes.
112
+ legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]
113
+ Exempt a registered artifact from retention TTL and reset (both --expired and plain)
114
+ legal-hold remove --artifact <name> Lift a hold
115
+ legal-hold list [--all] List active holds (--all also shows past holds whose expires_at has passed)
116
+ calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]
117
+ Opt-in: report a real-world outcome for a past finding, for calibration validation
118
+ calibration-report [--format cli|json] Aggregated, privacy-preserving calibration report from recorded feedback
106
119
  rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
107
120
  compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
108
121
  --list show bundled + BYO frameworks
@@ -136,6 +149,9 @@ Options:
136
149
  --fail-on-new (ci) block ONLY on findings this PR introduced vs
137
150
  the baseline ref — never the pre-existing backlog
138
151
  --policy <file.rego> ci-mode policy-as-code gate; deny[] rules fail the build (FR-SDLC-9)
152
+ --assurance advisory|standard|strict (ci) incomplete-analysis behavior (default: standard).
153
+ strict fails the build when any analyzer failed, timed out,
154
+ or was silently skipped by policy — independent of --fail-on (FR-204)
139
155
  --columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
140
156
  --confidence <0..1> Override per-profile confidence threshold
141
157
  --firehose Show ALL findings (ignore confidence threshold)
@@ -600,6 +616,18 @@ async function cmdScan(args) {
600
616
  }
601
617
  } catch {}
602
618
 
619
+ // FR-207: custom rule pack freshness. This is the one freshness leg that
620
+ // cannot be computed inside engine.js -- the pattern-rule DSL only runs
621
+ // here, after scan.scanHealth already exists -- so it's patched on via
622
+ // the same applyFreshness() engine.js uses for the other four legs (see
623
+ // that function's header comment in pipeline/scan-health.js).
624
+ if (scan.scanHealth) {
625
+ try {
626
+ const { applyFreshness } = await import('../src/pipeline/scan-health.js');
627
+ scan.scanHealth = applyFreshness(scan.scanHealth, { customRules: customRulesFreshness(targetAbs) });
628
+ } catch {}
629
+ }
630
+
603
631
  // EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
604
632
  // Bumps severity on actively-exploited CVEs so they sort to the top.
605
633
  if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
@@ -975,8 +1003,23 @@ async function cmdCi(args) {
975
1003
  const findings = normalizeFindings(scan);
976
1004
  const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
977
1005
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
1006
+ // FR-206: "reports show both finding count and scan-health status." A
1007
+ // 0-finding scan-exit is not the same claim as "the scan actually
1008
+ // finished cleanly" — an annotator error, a file timeout, or a skipped
1009
+ // analyzer already demotes scan.scanHealth.status to 'partial'
1010
+ // (scan-health.js), but until this line nothing in `ci`'s own printed
1011
+ // report ever surfaced it, so a broken analyzer could pass CI silently.
1012
+ // This is a REPORT fix, not a gate-policy change: whether an incomplete
1013
+ // scan should itself FAIL the build regardless of --fail-on is FR-204's
1014
+ // job (assurance modes advisory/standard/strict), not yet implemented —
1015
+ // deliberately not conflated with this fix.
1016
+ const _health = scan.scanHealth;
1017
+ const _healthLine = (_health && _health.status && _health.status !== 'complete')
1018
+ ? `[ci] ⚠ scan-health=${_health.status} — ${(_health.conditions || [])[0] || 'analysis did not complete cleanly'}${(_health.conditions || []).length > 1 ? ` (+${_health.conditions.length - 1} more)` : ''}\n`
1019
+ : `[ci] scan-health=${_health && _health.status ? _health.status : 'unknown'}\n`;
978
1020
  process.stderr.write(
979
1021
  `[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
1022
+ _healthLine +
980
1023
  (_canWriteCi
981
1024
  ? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
982
1025
  : `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
@@ -1000,6 +1043,23 @@ async function cmdCi(args) {
1000
1043
  }
1001
1044
  process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
1002
1045
  }
1046
+ // FR-204: assurance modes. Runs ALONGSIDE --fail-on and --policy, same
1047
+ // precedent as FR-SDLC-9's policy gate above — any of the three can fail
1048
+ // the build independently. `standard` (the default) never fails here;
1049
+ // `strict` fails the build when the scan itself was not fully complete,
1050
+ // regardless of how few/no findings resulted from the incomplete run.
1051
+ const { evaluateAssuranceMode, ASSURANCE_MODES, DEFAULT_ASSURANCE_MODE } = await import('../src/pipeline/assurance-mode.js');
1052
+ const assuranceMode = args.flags.assurance || DEFAULT_ASSURANCE_MODE;
1053
+ if (!ASSURANCE_MODES.includes(assuranceMode)) {
1054
+ console.error(`[ci] --assurance must be one of: ${ASSURANCE_MODES.join('|')} (got '${assuranceMode}')`);
1055
+ return 1;
1056
+ }
1057
+ const assuranceVerdict = evaluateAssuranceMode(assuranceMode, scan.scanHealth);
1058
+ if (!assuranceVerdict.ok) {
1059
+ console.error(`[ci] assurance gate FAILED (mode=${assuranceMode}): ${assuranceVerdict.reason}`);
1060
+ return 1;
1061
+ }
1062
+ if (assuranceMode === 'strict') process.stderr.write(`[ci] assurance gate PASSED (mode=strict)\n`);
1003
1063
  // R24 (PRD §5): PR-native net-new gate. With --fail-on-new and a baseline
1004
1064
  // ref, block ONLY on findings this PR INTRODUCED (vs the base ref), never on
1005
1065
  // the pre-existing backlog — the posture teams actually leave enabled.
@@ -1142,6 +1202,17 @@ async function cmdTriage(args) {
1142
1202
  console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
1143
1203
  if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
1144
1204
  console.log(` Total open: ${t.totalOpen}`);
1205
+ // FR-907: longitudinal production feedback — aggregates 5 already-
1206
+ // separate mechanisms (user suppression, accepted risk, invalid
1207
+ // finding, fixed finding, verification outcome) into one view, rather
1208
+ // than requiring an operator to check 5 different files by hand.
1209
+ const { productionFeedbackReport, renderProductionFeedbackSummary } = await import('../src/posture/production-feedback.js');
1210
+ const feedback = productionFeedbackReport(target, { sinceDays: days });
1211
+ const feedbackSummary = renderProductionFeedbackSummary(feedback);
1212
+ if (feedbackSummary) {
1213
+ console.log('');
1214
+ console.log(feedbackSummary);
1215
+ }
1145
1216
  return 0;
1146
1217
  }
1147
1218
  console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
@@ -1586,57 +1657,263 @@ async function cmdReset(args) {
1586
1657
  console.log(`No state to reset at ${stateDirPath}`);
1587
1658
  return 0;
1588
1659
  }
1589
- const WIPE = new Set([
1590
- 'validator-metrics.json',
1591
- 'triage-feedback.json',
1592
- 'scan-history.json',
1593
- 'last-scan.json',
1594
- 'last-scan.json.sig',
1595
- 'shadow-findings.json',
1596
- 'mcp-audit.log',
1597
- 'hook-throttle.json',
1598
- 'tickets.json',
1599
- 'streak.json',
1600
- 'findings.json',
1601
- 'findings.sarif',
1602
- 'findings.csv',
1603
- ]);
1604
- const WIPE_DIRS = new Set([
1605
- 'llm-cache',
1606
- 'fix-history',
1607
- 'fix-plans',
1608
- ]);
1660
+ // FR-703 (assurance-hardening PRD): registry-driven, not enumeration-
1661
+ // driven. The registry (src/posture/artifact-registry.js) is built from an
1662
+ // audit of every state-writing call site in src/ and bin/, not a
1663
+ // hand-maintained list that drifts as new artifacts are added — see that
1664
+ // module's header for the classification rules (generated vs
1665
+ // operator-config) and the corrections it made to this project's own PRD
1666
+ // evidence table along the way.
1667
+ const GENERATED = new Set(listGeneratedArtifacts().map(a => a.name));
1609
1668
  const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
1610
- const targets = [];
1611
- for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1612
- if (keep.has(entry.name)) continue;
1613
- if (WIPE.has(entry.name) || WIPE_DIRS.has(entry.name)) {
1614
- targets.push({ name: entry.name, dir: entry.isDirectory() });
1669
+ // FR-702: `--expired` narrows the reset to only artifacts that are past
1670
+ // their retention-class TTL (cache/scan/evidence/ticket/backup see
1671
+ // posture/retention-policy.js), rather than every registered generated
1672
+ // artifact. Reuses this same command's existing confirm/keep/preserve
1673
+ // machinery — a second, parallel deletion pathway would be a second
1674
+ // place to get path-safety wrong.
1675
+ let targets;
1676
+ if (args.flags.expired) {
1677
+ const { findExpiredArtifacts } = await import('../src/posture/retention-policy.js');
1678
+ targets = findExpiredArtifacts(scanRoot)
1679
+ .filter(a => !keep.has(a.name))
1680
+ .map(a => ({ name: a.name, dir: a.isDir, ageDays: a.ageDays, ttlDays: a.ttlDays, retentionClass: a.retentionClass }));
1681
+ } else {
1682
+ targets = [];
1683
+ for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1684
+ if (keep.has(entry.name)) continue;
1685
+ if (GENERATED.has(entry.name)) {
1686
+ targets.push({ name: entry.name, dir: entry.isDirectory() });
1687
+ }
1615
1688
  }
1616
1689
  }
1690
+ // FR-707: an artifact under an active legal hold is pulled OUT of the
1691
+ // deletion targets regardless of mode — a hold must protect against a
1692
+ // PLAIN `reset --yes` too (which otherwise deletes every registered
1693
+ // 'generated' artifact unconditionally), not just `--expired`, which
1694
+ // retention-policy.js's own findExpiredArtifacts already excludes on its
1695
+ // own as a second, defense-in-depth enforcement of the same guarantee.
1696
+ const { loadLegalHolds, isUnderHold } = await import('../src/posture/legal-hold.js');
1697
+ const holds = loadLegalHolds(scanRoot);
1698
+ const heldDetail = [];
1699
+ targets = targets.filter(t => {
1700
+ const hold = isUnderHold(t.name, holds);
1701
+ if (!hold) return true;
1702
+ heldDetail.push({ name: t.name, dir: !!t.dir, reason: `active legal hold (owner: ${hold.owner}, reason: ${hold.reason})` });
1703
+ return false;
1704
+ });
1617
1705
  if (!targets.length) {
1618
- console.log(`Nothing to reset under ${stateDirPath}.`);
1706
+ console.log(args.flags.expired ? `Nothing expired under ${stateDirPath}.` : `Nothing to reset under ${stateDirPath}.`);
1707
+ if (heldDetail.length) {
1708
+ console.log(`Preserving ${heldDetail.length} artifact(s) under active legal hold: ${heldDetail.map(h => h.name).sort().join(', ')}`);
1709
+ }
1619
1710
  return 0;
1620
1711
  }
1621
- console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
1622
- for (const t of targets) console.log(` ${t.name}${t.dir ? '/' : ''}`);
1712
+ console.log(`agentic-security reset${args.flags.expired ? ' --expired' : ''} — will remove from ${stateDirPath}:`);
1713
+ for (const t of targets) {
1714
+ const ttlNote = args.flags.expired ? ` (${t.retentionClass}, ${t.ageDays.toFixed(1)}d old, ttl ${t.ttlDays}d)` : '';
1715
+ console.log(` ${t.name}${t.dir ? '/' : ''}${ttlNote}`);
1716
+ }
1623
1717
  console.log('');
1624
- console.log('Preserving operator-authored config: rules.yml, rules/, license-policy.yml, trusted-keys.json, ruleset-version.json');
1718
+ // FR-703: report what is ACTUALLY present and being left alone, rather
1719
+ // than a hardcoded 5-name string that drifted behind the real config
1720
+ // surface (risk-config.yml, profile.yml, suppressions.yml, and others were
1721
+ // silently omitted from the old message even though they were already
1722
+ // correctly never wiped).
1723
+ const targetNames = new Set(targets.map(t => t.name));
1724
+ const heldNames = new Set(heldDetail.map(h => h.name));
1725
+ const preserved = heldDetail.map(h => h.name + (h.dir ? '/' : ''));
1726
+ const preservedDetail = heldDetail.map(h => ({ name: h.name, reason: h.reason }));
1727
+ for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1728
+ if (keep.has(entry.name) || targetNames.has(entry.name) || heldNames.has(entry.name)) continue;
1729
+ if (!args.flags.expired && GENERATED.has(entry.name)) continue;
1730
+ preserved.push(entry.name + (entry.isDirectory() ? '/' : ''));
1731
+ preservedDetail.push({
1732
+ name: entry.name,
1733
+ reason: args.flags.expired && GENERATED.has(entry.name)
1734
+ ? 'generated artifact still within its retention TTL'
1735
+ : 'operator-authored configuration',
1736
+ });
1737
+ }
1738
+ if (preserved.length) {
1739
+ console.log(args.flags.expired
1740
+ ? `Preserving operator-authored config and generated artifacts still within their TTL: ${preserved.sort().join(', ')}`
1741
+ : `Preserving operator-authored config: ${preserved.sort().join(', ')}`);
1742
+ }
1743
+ // FR-706: every reset invocation — dry-run or applied — leaves a durable,
1744
+ // structured record of what it planned, deleted, preserved, or failed to
1745
+ // delete, not just console output that vanishes once the terminal
1746
+ // scrolls past it.
1747
+ const { buildDeletionReport, writeDeletionReport } = await import('../src/posture/state-lifecycle-report.js');
1748
+ const mode = args.flags.expired ? 'reset --expired' : 'reset';
1625
1749
  if (!args.flags.yes) {
1626
1750
  console.log('');
1627
1751
  console.log('Pass --yes to proceed (or --keep <name,name> to spare specific items).');
1752
+ writeDeletionReport(scanRoot, buildDeletionReport({
1753
+ mode, dryRun: true, root: scanRoot,
1754
+ items: targets.map(t => ({
1755
+ name: t.name, dir: !!t.dir, status: 'planned',
1756
+ retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null,
1757
+ })),
1758
+ preserved: preservedDetail,
1759
+ }));
1628
1760
  return 0;
1629
1761
  }
1762
+ const outcomes = [];
1630
1763
  for (const t of targets) {
1631
1764
  const p = path.join(stateDirPath, t.name);
1765
+ const base = { name: t.name, dir: !!t.dir, retentionClass: t.retentionClass || null, ageDays: t.ageDays ?? null, ttlDays: t.ttlDays ?? null };
1632
1766
  try {
1633
1767
  if (t.dir) await fsp.rm(p, { recursive: true, force: true });
1634
1768
  else await fsp.rm(p, { force: true });
1769
+ outcomes.push({ ...base, status: 'deleted' });
1635
1770
  } catch (e) {
1636
1771
  console.error(`reset: failed to remove ${p}: ${e.message}`);
1772
+ outcomes.push({ ...base, status: 'failed', error: e.message });
1637
1773
  }
1638
1774
  }
1639
- console.log(`Reset ${targets.length} item(s). Operator-authored config preserved.`);
1775
+ const failedCount = outcomes.filter(o => o.status === 'failed').length;
1776
+ console.log(`Reset ${outcomes.length - failedCount} item(s)${failedCount ? `, ${failedCount} failed` : ''}. Operator-authored config preserved.`);
1777
+ const reportPath = writeDeletionReport(scanRoot, buildDeletionReport({
1778
+ mode, dryRun: false, root: scanRoot, items: outcomes, preserved: preservedDetail,
1779
+ }));
1780
+ if (reportPath) console.log(`Deletion report: ${path.relative(scanRoot, reportPath)}`);
1781
+ return failedCount ? 1 : 0;
1782
+ }
1783
+
1784
+ // `agentic-security export --out <dir> [--root <path>]`
1785
+ //
1786
+ // FR-706 (assurance-hardening PRD): the "exported" half of "operators can
1787
+ // prove what was exported, deleted, retained, or failed." Copies every
1788
+ // CURRENTLY-PRESENT registered artifact — generated AND operator-config —
1789
+ // from .agentic-security/ into an operator-chosen destination, alongside a
1790
+ // manifest naming exactly what was copied (with a content hash for files)
1791
+ // and what failed. Unlike `reset`, classification does not gate inclusion:
1792
+ // an export is a snapshot for the operator's own records, migration, or
1793
+ // legal-preservation purposes, not a deletion decision.
1794
+ async function cmdExport(args) {
1795
+ const scanRoot = path.resolve(args.flags.root || '.');
1796
+ const stateDirPath = stateDir(scanRoot);
1797
+ if (!fs.existsSync(stateDirPath)) {
1798
+ console.log(`No state to export at ${stateDirPath}`);
1799
+ return 0;
1800
+ }
1801
+ if (!args.flags.out) {
1802
+ console.error('export: --out <dir> is required.');
1803
+ return 2;
1804
+ }
1805
+ const outDir = path.resolve(args.flags.out);
1806
+ await fsp.mkdir(outDir, { recursive: true });
1807
+
1808
+ const { ARTIFACT_REGISTRY } = await import('../src/posture/artifact-registry.js');
1809
+ const { buildExportReport, writeExportReport } = await import('../src/posture/state-lifecycle-report.js');
1810
+ const { createHash } = await import('node:crypto');
1811
+ const present = new Set((await fsp.readdir(stateDirPath, { withFileTypes: true })).map(e => e.name));
1812
+
1813
+ const items = [];
1814
+ for (const artifact of ARTIFACT_REGISTRY) {
1815
+ if (!present.has(artifact.name)) continue;
1816
+ const src = path.join(stateDirPath, artifact.name);
1817
+ const dest = path.join(outDir, artifact.name);
1818
+ try {
1819
+ await fsp.mkdir(path.dirname(dest), { recursive: true });
1820
+ if (artifact.kind === 'dir') {
1821
+ await fsp.cp(src, dest, { recursive: true });
1822
+ items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256: null });
1823
+ } else {
1824
+ await fsp.copyFile(src, dest);
1825
+ const sha256 = createHash('sha256').update(fs.readFileSync(src)).digest('hex');
1826
+ items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'exported', sha256 });
1827
+ }
1828
+ } catch (e) {
1829
+ items.push({ name: artifact.name, classification: artifact.classification, retentionClass: artifact.retentionClass || null, status: 'failed', error: e.message });
1830
+ }
1831
+ }
1832
+
1833
+ const report = buildExportReport({ root: scanRoot, outDir, items });
1834
+ const manifestPath = path.join(outDir, 'export-manifest.json');
1835
+ await fsp.writeFile(manifestPath, JSON.stringify(report, null, 2) + '\n');
1836
+ writeExportReport(scanRoot, report); // last-action record kept under .agentic-security/ too, same as deletion-report.json
1837
+
1838
+ const failedCount = items.filter(i => i.status === 'failed').length;
1839
+ console.log(`Exported ${items.length - failedCount} artifact(s) to ${outDir}${failedCount ? `, ${failedCount} failed` : ''}.`);
1840
+ console.log(`Manifest: ${manifestPath}`);
1841
+ return failedCount ? 1 : 0;
1842
+ }
1843
+
1844
+ // `agentic-security legal-hold add --artifact <name> --owner <id> --reason <text> [--expires <date>]`
1845
+ // `agentic-security legal-hold remove --artifact <name>`
1846
+ // `agentic-security legal-hold list [--all]`
1847
+ //
1848
+ // FR-707 (assurance-hardening PRD): identity-bound (--owner), reasoned
1849
+ // (--reason), time-bounded where applicable (--expires is optional — an
1850
+ // indefinite hold has no end date), and auditable (`list` reads back
1851
+ // exactly what was recorded). See posture/legal-hold.js for the full
1852
+ // design rationale and how this is enforced inside `cmdReset`.
1853
+ async function cmdLegalHold(args) {
1854
+ const scanRoot = path.resolve(args.flags.root || '.');
1855
+ const sub = args._[1];
1856
+ const { addLegalHold, removeLegalHold, listLegalHolds } = await import('../src/posture/legal-hold.js');
1857
+
1858
+ if (sub === 'add') {
1859
+ const result = addLegalHold(scanRoot, {
1860
+ artifact: args.flags.artifact, owner: args.flags.owner, reason: args.flags.reason, expires_at: args.flags.expires,
1861
+ });
1862
+ if (!result.ok) { console.error(`legal-hold add: ${result.reason}`); return 2; }
1863
+ console.log(`Legal hold placed on "${result.hold.artifact}" (owner: ${result.hold.owner}${result.hold.expires_at ? `, expires ${result.hold.expires_at}` : ', indefinite'}).`);
1864
+ return 0;
1865
+ }
1866
+ if (sub === 'remove') {
1867
+ if (!args.flags.artifact) { console.error('legal-hold remove: --artifact <name> is required.'); return 2; }
1868
+ const removedCount = removeLegalHold(scanRoot, args.flags.artifact);
1869
+ console.log(removedCount ? `Removed ${removedCount} hold(s) on "${args.flags.artifact}".` : `No hold found on "${args.flags.artifact}".`);
1870
+ return 0;
1871
+ }
1872
+ if (sub === 'list' || !sub) {
1873
+ const holds = listLegalHolds(scanRoot, { includeExpired: !!args.flags.all });
1874
+ if (!holds.length) { console.log(args.flags.all ? 'No legal holds recorded (ever).' : 'No active legal holds.'); return 0; }
1875
+ for (const h of holds) {
1876
+ console.log(` ${h.artifact} owner=${h.owner} reason="${h.reason}" expires=${h.expires_at || 'never'} created=${h.created_at}`);
1877
+ }
1878
+ return 0;
1879
+ }
1880
+ console.error(`legal-hold: unknown subcommand "${sub}" (expected add|remove|list).`);
1881
+ return 2;
1882
+ }
1883
+
1884
+ // `agentic-security calibration-feedback record --finding-id <id> --outcome accepted-risk|realized-incident [--note <text>]`
1885
+ // `agentic-security calibration-report`
1886
+ //
1887
+ // FR-806 (assurance-hardening PRD): genuinely opt-in — nothing here is ever
1888
+ // written by a scan. See posture/calibration-feedback.js for the full
1889
+ // scoping rationale (why "aggregated" means within-installation, why the
1890
+ // record snapshots only prediction signals and never file/line/vuln text).
1891
+ async function cmdCalibrationFeedback(args) {
1892
+ const scanRoot = path.resolve(args.flags.root || '.');
1893
+ const sub = args._[1];
1894
+ const { recordCalibrationFeedback, OUTCOMES } = await import('../src/posture/calibration-feedback.js');
1895
+
1896
+ if (sub === 'record') {
1897
+ const result = recordCalibrationFeedback(scanRoot, {
1898
+ findingId: args.flags['finding-id'], outcome: args.flags.outcome, note: args.flags.note,
1899
+ });
1900
+ if (!result.ok) { console.error(`calibration-feedback record: ${result.reason}`); return 2; }
1901
+ // The persisted record.findingId is privacy-safe (a hash), not the
1902
+ // caller's own input -- echo back what the operator actually typed.
1903
+ console.log(`Recorded "${result.record.outcome}" for finding ${args.flags['finding-id']}.`);
1904
+ return 0;
1905
+ }
1906
+ console.error(`calibration-feedback: unknown subcommand "${sub}" (expected record --finding-id <id> --outcome ${OUTCOMES.join('|')} [--note <text>]).`);
1907
+ return 2;
1908
+ }
1909
+
1910
+ async function cmdCalibrationReport(args) {
1911
+ const scanRoot = path.resolve(args.flags.root || '.');
1912
+ const { buildCalibrationReport, renderCalibrationReportSummary } = await import('../src/posture/calibration-feedback.js');
1913
+ const report = buildCalibrationReport(scanRoot);
1914
+ if (args.flags.format === 'json') { writeStdout(JSON.stringify(report, null, 2) + '\n'); return 0; }
1915
+ const summary = renderCalibrationReportSummary(report);
1916
+ console.log(summary || 'No calibration feedback recorded yet — this is opt-in; see `calibration-feedback record --help`.');
1640
1917
  return 0;
1641
1918
  }
1642
1919
 
@@ -1989,8 +2266,15 @@ async function cmdVerifyAttestation(args) {
1989
2266
  if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
1990
2267
 
1991
2268
  let bundle;
1992
- try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
1993
- catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
2269
+ try {
2270
+ const raw = fs.readFileSync(path.resolve(file), 'utf8');
2271
+ // FR-705: transparently decrypt if this is an encrypted artifact (e.g.
2272
+ // an encrypted compliance-evidence.json) — a no-op for any plaintext
2273
+ // file, including every artifact from before encryption was ever
2274
+ // configured.
2275
+ const { maybeDecryptForRead } = await import('../src/posture/encryption-provider.js');
2276
+ bundle = JSON.parse(maybeDecryptForRead(raw));
2277
+ } catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
1994
2278
 
1995
2279
  const runAttestation = _asRunAttestation(bundle);
1996
2280
  if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
@@ -2000,6 +2284,24 @@ async function cmdVerifyAttestation(args) {
2000
2284
  try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
2001
2285
  catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
2002
2286
 
2287
+ // FR-505: a compliance evidence manifest (@type: ComplianceEvidence) is a
2288
+ // third distinct shape this same command can be handed — auto-detected
2289
+ // the same way run-attestation-vs-finding-bundle already is, rather than
2290
+ // adding a fourth CLI command for what is, from an operator's point of
2291
+ // view, the same question ("is this artifact exactly what was signed").
2292
+ if (bundle['@type'] === 'ComplianceEvidence') {
2293
+ const { verifyComplianceEvidence } = await import('../src/posture/compliance-evidence-signing.js');
2294
+ const cr = verifyComplianceEvidence(bundle, publicKeyPem);
2295
+ if (!cr.ok) { console.error(`✗ INVALID — ${cr.reason}`); return 1; }
2296
+ console.log('✓ VALID — the compliance evidence manifest is exactly what the signer produced.');
2297
+ console.log('');
2298
+ console.log(` framework: ${bundle.framework} version: ${bundle.version}`);
2299
+ console.log(` generated: ${bundle.generatedAt}`);
2300
+ if (bundle.evidenceDigest) console.log(` evidence digest: ${bundle.evidenceDigest}`);
2301
+ console.log(` compliant: ${bundle.summary?.compliant ?? 'n/a'} non-compliant: ${bundle.summary?.nonCompliant ?? 'n/a'} stale: ${bundle.summary?.stale ?? 0} gap: ${bundle.summary?.gap ?? 0}`);
2302
+ return 0;
2303
+ }
2304
+
2003
2305
  const r = verifyEvidenceBundle(bundle, publicKeyPem);
2004
2306
  if (!r.ok) {
2005
2307
  console.error(`✗ INVALID — ${r.reason}`);
@@ -2016,6 +2318,84 @@ async function cmdVerifyAttestation(args) {
2016
2318
  return 0;
2017
2319
  }
2018
2320
 
2321
+ // FR-1001 (assurance-hardening PRD): "effective policy is explainable."
2322
+ // Loads whichever organization/repository/environment policy bundles exist
2323
+ // under .agentic-security/policy-bundles/, verifies each against an
2324
+ // operator-supplied public key, merges the valid ones (most-specific-wins),
2325
+ // and prints BOTH the effective policy with per-key provenance AND every
2326
+ // rejected bundle's scope and reason — a rejection is reported, never
2327
+ // silently absent, so "tampered or expired policy is rejected" is visible
2328
+ // through this same real command, not just a library-level guarantee.
2329
+ async function cmdPolicyExplain(args) {
2330
+ const scanRoot = path.resolve(args.flags.root || '.');
2331
+ const { loadPolicyBundles, loadPolicyPublicKey, resolveEffectivePolicy } = await import('../src/posture/policy-bundle.js');
2332
+ const entries = loadPolicyBundles(scanRoot);
2333
+ if (!entries.length) {
2334
+ console.log('No policy bundles found under .agentic-security/policy-bundles/ (organization.json, repository.json, environment.json).');
2335
+ return 0;
2336
+ }
2337
+ let publicKeyPem = null;
2338
+ const keyFile = args.flags['public-key'];
2339
+ if (keyFile) {
2340
+ try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
2341
+ catch (e) { console.error(`Could not read public key at ${keyFile}: ${e.message}`); return 2; }
2342
+ } else {
2343
+ publicKeyPem = loadPolicyPublicKey(scanRoot);
2344
+ }
2345
+ const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
2346
+
2347
+ console.log(`Accepted (${accepted.length}): ${accepted.join(', ') || 'none'}`);
2348
+ if (rejected.length) {
2349
+ console.log(`Rejected (${rejected.length}):`);
2350
+ for (const r of rejected) console.log(` ✗ ${r.scope}: ${r.reason}`);
2351
+ }
2352
+ console.log('');
2353
+ console.log('Effective policy:');
2354
+ const keys = Object.keys(effective).sort();
2355
+ if (!keys.length) {
2356
+ console.log(' (empty — no accepted bundle contributed any key)');
2357
+ } else {
2358
+ for (const k of keys) console.log(` ${k} = ${JSON.stringify(effective[k])} [from: ${provenance[k]}]`);
2359
+ }
2360
+ return 0;
2361
+ }
2362
+
2363
+ // FR-1001: the operator-facing signing side, mirroring cmdAttest's own
2364
+ // generate-key-if-absent pattern. Genuinely optional — an org can sign
2365
+ // bundles with any Ed25519 tooling that produces the same canonical bytes
2366
+ // (canonicalPolicyBytes is exported for exactly that interop reason) — but
2367
+ // without SOME real caller for ensurePolicyKeyPair, this codebase's own
2368
+ // dead-module guard is right to flag it: a key-generation function nobody
2369
+ // calls is exactly the kind of code this project's premortems exist to
2370
+ // catch, per posture/CLAUDE.md's dead-module convention.
2371
+ async function cmdPolicySign(args) {
2372
+ const scanRoot = path.resolve(args.flags.root || '.');
2373
+ const { ensurePolicyKeyPair, buildPolicyBundle, signPolicyBundle } = await import('../src/posture/policy-bundle.js');
2374
+ const scope = args.flags.scope;
2375
+ if (!['organization', 'repository', 'environment'].includes(scope)) {
2376
+ console.error('Usage: agentic-security policy-sign --scope <organization|repository|environment> --policy <json-file> [--expires <ISO-date>] [--out <path>]');
2377
+ return 2;
2378
+ }
2379
+ const policyFile = args.flags.policy;
2380
+ if (!policyFile) { console.error('--policy <json-file> is required (the policy object to sign).'); return 2; }
2381
+ let policy;
2382
+ try { policy = JSON.parse(fs.readFileSync(path.resolve(policyFile), 'utf8')); }
2383
+ catch (e) { console.error(`Could not read --policy file: ${e.message}`); return 2; }
2384
+
2385
+ const kp = ensurePolicyKeyPair();
2386
+ if (kp.created) console.error(`Generated a new policy-signing key at ${kp.privateKey} (public: ${kp.publicKey}). Distribute the PUBLIC key to every repository that must trust bundles you sign.`);
2387
+
2388
+ const bundle = buildPolicyBundle(scope, policy, { expiresAt: args.flags.expires || null });
2389
+ if (!bundle) { console.error('Could not build a bundle — check --scope and that --policy is a JSON object.'); return 2; }
2390
+ const signed = signPolicyBundle(bundle, kp.privateKeyPem);
2391
+
2392
+ const outFile = args.flags.out || `${scope}.json`;
2393
+ fs.writeFileSync(path.resolve(outFile), JSON.stringify(signed, null, 2) + '\n');
2394
+ console.log(`✓ signed ${scope} policy bundle written to ${outFile}`);
2395
+ console.log(` public key for verification: ${kp.publicKey}`);
2396
+ return 0;
2397
+ }
2398
+
2019
2399
  async function cmdRuleSynth(args) {
2020
2400
  const scanRoot = path.resolve(args.flags.root || '.');
2021
2401
  const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
@@ -2099,11 +2479,15 @@ async function cmdFix(args) {
2099
2479
  return 0;
2100
2480
  }
2101
2481
 
2102
- // Both --preview and --apply require an actual replacement to operate on.
2103
- // For now we accept either f.fix.replacement (full new file content) or
2104
- // f.fix.replaceLine (single-line replacement). Anything else falls back
2105
- // to the template output and tells the user to run the security-fixer subagent.
2106
- const absFile = path.resolve(scanRoot, f.file);
2482
+ // FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
2483
+ // before the write a traversal or symlink-planted f.file was previously
2484
+ // followed unquestioned for both the preview diff and the apply write (no
2485
+ // check existed on this path at all, unlike MCP's apply_fix). Applies to
2486
+ // preview too since both modes read from the same (until now, unconfined)
2487
+ // location.
2488
+ let absFile;
2489
+ try { absFile = confinePath(scanRoot, f.file, 'finding.file'); }
2490
+ catch (e) { console.error(`path-escape refused: ${e.message}`); return 4; }
2107
2491
  if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
2108
2492
  const originalContent = await fsp.readFile(absFile, 'utf8');
2109
2493
  let newContent = null;
@@ -2127,16 +2511,56 @@ async function cmdFix(args) {
2127
2511
  return 0;
2128
2512
  }
2129
2513
 
2130
- // --apply. Premortem 4R-8: pass stableId from the engine directly so the
2131
- // recover() cross-check is robust against line-number drift (f.id is
2132
- // `${file}:${line}:${rule}` and rotates when the user edits the file).
2133
- const entry = await applyFix({
2134
- scanRoot, file: f.file, originalContent, newContent,
2135
- findingId: f.id, stableId: f.stableId || null,
2136
- ruleId: f.cwe || f.title, vuln: f.vuln || f.title,
2514
+ // --apply. FR-301/FR-302/FR-304 (assurance-hardening PRD): this used to
2515
+ // WARN on failed integrity and apply anyway, and skip verification
2516
+ // entirely (no rescan, no lint) — the same write-time safety gate MCP's
2517
+ // apply_fix already enforces for its caller-patch branch is now required
2518
+ // here too, through the one shared service.
2519
+ if (sigVerified !== true) {
2520
+ console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
2521
+ return 4;
2522
+ }
2523
+ // FR-307/D-0024: before this, the CLI had no way to supply approval
2524
+ // evidence at all — a high-impact change (auth/authZ/crypto/PII/schema/
2525
+ // infra-privilege/public-API) was unconditionally refused via --apply
2526
+ // with no path to ever approve it, since fixMeta was never built here.
2527
+ // --approved-by/--approval-reason are optional and no-op for a candidate
2528
+ // that isn't high-impact — only apply-fix-service.js's own gate decides
2529
+ // whether they were needed.
2530
+ const approvedBy = args.flags['approved-by'] || null;
2531
+ const approvalReason = args.flags['approval-reason'] || null;
2532
+ // FR-1003: --author is optional and only has an effect when an operator
2533
+ // has opted into separation-of-duties in authorized-approvers.json — see
2534
+ // approver-registry.js's checkSeparationOfDuties.
2535
+ const patchAuthor = args.flags['author'] || null;
2536
+ const fixMeta = (approvedBy || approvalReason || patchAuthor)
2537
+ ? { approval: { approvedBy: approvedBy || '', reason: approvalReason || '' }, ...(patchAuthor ? { author: patchAuthor } : {}) }
2538
+ : null;
2539
+ const result = await applyVerifiedFix({
2540
+ scanRoot,
2541
+ finding: { file: f.file, id: f.id, stableId: f.stableId || null, ruleId: f.cwe || f.title, vuln: f.vuln || f.title },
2542
+ files: { [f.file]: newContent },
2543
+ fixMeta,
2137
2544
  });
2138
- console.log(`✓ applied fix ${entry.id} (file: ${entry.file})`);
2545
+ if (!result.ok) {
2546
+ console.error(`Refusing to apply: ${result.reason}`);
2547
+ if (result.budgetExceeded) console.error(` (${result.attempts}/${result.maxAttempts} attempts already made for this finding)`);
2548
+ return 4;
2549
+ }
2550
+ const entry = result.written[0];
2551
+ console.log(`✓ applied fix ${entry.historyId} (file: ${entry.file})`);
2139
2552
  console.log(` backup: ${entry.backupPath}`);
2553
+ // FR-305: this used to unconditionally print "lint" as part of the
2554
+ // verified line, even when the linter was skipped (not installed) or no
2555
+ // test runner existed — claiming a required leg ran when it did not is
2556
+ // exactly the mislabeling FR-305 exists to prevent. Say plainly when the
2557
+ // pass was degraded and name what was skipped.
2558
+ if (result.verifiedFull) {
2559
+ console.log(' verified: yes — fully verified (rescan clean, no new medium+ finding, lint, tests)');
2560
+ } else {
2561
+ const degraded = (result.verify?.degradedLegs || []).join('; ') || 'a required leg';
2562
+ console.log(` verified: yes, but NOT fully verified — rescan clean, no new medium+ finding; ${degraded}`);
2563
+ }
2140
2564
  console.log(` revert with: agentic-security undo`);
2141
2565
  return 0;
2142
2566
  }
@@ -2308,10 +2732,16 @@ async function main() {
2308
2732
  case 'validator-cache': process.exit(await cmdValidatorCache(args));
2309
2733
  case 'verify': process.exit(await cmdVerify(args));
2310
2734
  case 'reset': process.exit(await cmdReset(args));
2735
+ case 'export': process.exit(await cmdExport(args));
2736
+ case 'legal-hold': process.exit(await cmdLegalHold(args));
2737
+ case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
2738
+ case 'calibration-report': process.exit(await cmdCalibrationReport(args));
2311
2739
  case 'hunt': process.exit(await cmdHunt(args));
2312
2740
  case 'compliance': process.exit(await cmdCompliance(args));
2313
2741
  case 'attest': process.exit(await cmdAttest(args));
2314
2742
  case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
2743
+ case 'policy-explain': process.exit(await cmdPolicyExplain(args));
2744
+ case 'policy-sign': process.exit(await cmdPolicySign(args));
2315
2745
  case 'rule-synth': process.exit(await cmdRuleSynth(args));
2316
2746
  case 'digest': process.exit(await cmdDigest(args));
2317
2747
  case 'setup': process.exit(await cmdSetup(args));