@clear-capabilities/agentic-security-scanner 0.134.0 → 0.136.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 (170) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/bin/agentic-security-audit.js +2 -1
  3. package/bin/agentic-security-consistency.js +2 -1
  4. package/bin/agentic-security.js +448 -74
  5. package/dist/113.index.js +16 -7
  6. package/dist/117.index.js +3 -1
  7. package/dist/178.index.js +1 -1
  8. package/dist/207.index.js +5 -4
  9. package/dist/220.index.js +5 -3
  10. package/dist/238.index.js +4 -4
  11. package/dist/317.index.js +300 -0
  12. package/dist/384.index.js +1 -1
  13. package/dist/435.index.js +196 -21
  14. package/dist/444.index.js +20 -11
  15. package/dist/449.index.js +8 -1
  16. package/dist/513.index.js +7 -3
  17. package/dist/526.index.js +6 -6
  18. package/dist/637.index.js +1 -1
  19. package/dist/675.index.js +7 -5
  20. package/dist/839.index.js +4 -3
  21. package/dist/905.index.js +1173 -0
  22. package/dist/agentic-security.mjs +14 -14
  23. package/dist/agentic-security.mjs.sha256 +1 -1
  24. package/dist/compliance-frameworks/ccpa.json +32 -0
  25. package/dist/compliance-frameworks/eu-ai-act.json +51 -0
  26. package/dist/compliance-frameworks/gdpr.json +45 -0
  27. package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
  28. package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
  29. package/dist/compliance-frameworks/nist-csf-2.json +73 -0
  30. package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
  31. package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
  32. package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
  33. package/package.json +24 -12
  34. package/src/badge.js +2 -1
  35. package/src/dataflow/CLAUDE.md +10 -4
  36. package/src/dataflow/builtin-summaries.js +1 -1
  37. package/src/dataflow/cross-service-taint.js +2 -1
  38. package/src/dataflow/engine.js +324 -60
  39. package/src/dataflow/ifds-precise.js +6 -4
  40. package/src/dataflow/implicit-flow.js +68 -36
  41. package/src/dataflow/incremental.js +25 -8
  42. package/src/dataflow/index.js +2 -1
  43. package/src/dataflow/proven-clean.js +41 -0
  44. package/src/dataflow/sanitizer-gate.js +35 -9
  45. package/src/dataflow/sanitizer-proof.js +21 -3
  46. package/src/dataflow/stub-aware-filter.js +36 -13
  47. package/src/dataflow/summaries.js +21 -2
  48. package/src/discovery/CLAUDE.md +10 -0
  49. package/src/discovery/index.js +175 -3
  50. package/src/discovery/llm-invoke.js +90 -1
  51. package/src/discovery/memory.js +163 -0
  52. package/src/engine.js +247 -50
  53. package/src/integrations/tickets.js +7 -6
  54. package/src/ir/CLAUDE.md +4 -1
  55. package/src/ir/balanced-call.js +55 -0
  56. package/src/ir/ir-stats.js +1 -1
  57. package/src/ir/parser-cpp.js +1 -1
  58. package/src/ir/parser-cs.js +62 -9
  59. package/src/ir/parser-go.js +29 -11
  60. package/src/ir/parser-java.js +96 -19
  61. package/src/ir/parser-js.js +151 -20
  62. package/src/ir/parser-php.js +44 -9
  63. package/src/ir/parser-rb.js +37 -7
  64. package/src/ir/ssa.js +6 -1
  65. package/src/leaderboard.js +3 -2
  66. package/src/llm-validator/consistency.js +6 -2
  67. package/src/llm-validator/index.js +1 -2
  68. package/src/lsp/server.js +28 -2
  69. package/src/mcp/CLAUDE.md +9 -2
  70. package/src/mcp/audit.js +2 -1
  71. package/src/mcp/redact.js +26 -0
  72. package/src/mcp/tools.js +159 -17
  73. package/src/posture/CLAUDE.md +45 -8
  74. package/src/posture/accuracy-scorecard.js +67 -1
  75. package/src/posture/agents-memory.js +5 -3
  76. package/src/posture/aibom.js +12 -8
  77. package/src/posture/auditor-walkthrough.js +111 -10
  78. package/src/posture/auth-posture-import.js +5 -4
  79. package/src/posture/autopilot.js +8 -1
  80. package/src/posture/calibration-drift.js +11 -5
  81. package/src/posture/calibration.js +24 -2
  82. package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
  83. package/src/posture/compliance-frameworks/owasp-asvs-5.json +1 -1
  84. package/src/posture/compliance-policy.js +40 -10
  85. package/src/posture/confidence.js +44 -10
  86. package/src/posture/corpus-enroll.js +9 -5
  87. package/src/posture/corpus-match.js +19 -0
  88. package/src/posture/csharp-analysis.js +62 -3
  89. package/src/posture/custom-rules.js +7 -5
  90. package/src/posture/cve-alert-daemon.js +6 -5
  91. package/src/posture/dep-add-guard.js +2 -1
  92. package/src/posture/deploy-platform.js +4 -1
  93. package/src/posture/deterministic.js +3 -2
  94. package/src/posture/drift.js +7 -1
  95. package/src/posture/epss.js +13 -1
  96. package/src/posture/evidence-bundle.js +276 -0
  97. package/src/posture/exploitability-probability.js +15 -2
  98. package/src/posture/falsification.js +23 -2
  99. package/src/posture/feature-flags.js +3 -2
  100. package/src/posture/findings-memory.js +3 -3
  101. package/src/posture/fix-history.js +5 -2
  102. package/src/posture/fix-metrics.js +5 -5
  103. package/src/posture/fix-plan.js +2 -1
  104. package/src/posture/fix-verify-loop.js +10 -1
  105. package/src/posture/grader-calibration.js +3 -4
  106. package/src/posture/iac-reachability.js +14 -8
  107. package/src/posture/integrity.js +25 -7
  108. package/src/posture/intent-context.js +2 -1
  109. package/src/posture/learning.js +4 -3
  110. package/src/posture/license-attributions.js +5 -7
  111. package/src/posture/license-graph.js +2 -1
  112. package/src/posture/license-policy.js +2 -1
  113. package/src/posture/model-rescan.js +69 -3
  114. package/src/posture/mttr.js +5 -0
  115. package/src/posture/network-policy-import.js +3 -2
  116. package/src/posture/poc-inprocess.js +27 -8
  117. package/src/posture/pqc-migration-plan.js +7 -5
  118. package/src/posture/pr-augment.js +8 -5
  119. package/src/posture/privacy-framework.js +262 -0
  120. package/src/posture/regression-test-gen.js +23 -8
  121. package/src/posture/reverse-blast-radius.js +5 -1
  122. package/src/posture/risk-dollars.js +20 -3
  123. package/src/posture/router.js +5 -4
  124. package/src/posture/ruleset-version.js +2 -2
  125. package/src/posture/runtime-correlation.js +2 -1
  126. package/src/posture/sbom-diff.js +12 -3
  127. package/src/posture/sca-policy.js +7 -4
  128. package/src/posture/scan-checkpoint.js +15 -0
  129. package/src/posture/secret-history.js +20 -11
  130. package/src/posture/security-trend.js +7 -1
  131. package/src/posture/stack-playbook.js +22 -1
  132. package/src/posture/state-dir.js +34 -0
  133. package/src/posture/telemetry-ingest.js +4 -3
  134. package/src/posture/threat-model-auto.js +4 -1
  135. package/src/posture/threat-model-grounding.js +13 -3
  136. package/src/posture/time-to-fix.js +3 -2
  137. package/src/posture/triage-memory.js +3 -2
  138. package/src/posture/validator-metrics.js +10 -3
  139. package/src/posture/verifier.js +32 -57
  140. package/src/posture/waf-ingest.js +6 -5
  141. package/src/posture/watch-mode.js +4 -3
  142. package/src/report/index.js +183 -14
  143. package/src/runScan.js +1 -1
  144. package/src/sast/_comment-strip.js +15 -4
  145. package/src/sast/_secret-entropy.js +1 -1
  146. package/src/sast/authz.js +6 -4
  147. package/src/sast/bench-shape/index.js +2 -7
  148. package/src/sast/claude-md-prompt-injection.js +14 -3
  149. package/src/sast/cloud-iam.js +60 -7
  150. package/src/sast/code-injection-multilang.js +29 -0
  151. package/src/sast/cpp-bench-extras.js +1 -1
  152. package/src/sast/csrf.js +7 -5
  153. package/src/sast/env-hygiene.js +5 -2
  154. package/src/sast/iac-terraform.js +25 -0
  155. package/src/sast/java-bench-extras.js +1 -1
  156. package/src/sast/java-constant-fold.js +5 -5
  157. package/src/sast/llm-owasp.js +4 -2
  158. package/src/sast/mcp-audit.js +7 -0
  159. package/src/sast/pipeline.js +8 -0
  160. package/src/sast/prompt-template.js +8 -6
  161. package/src/sast/prototype-pollution.js +6 -2
  162. package/src/sast/redos-nfa.js +6 -6
  163. package/src/sast/secret-concat.js +13 -2
  164. package/src/sast/ssrf-cloud-metadata.js +6 -3
  165. package/src/sast/xss-reflected-multilang.js +1 -1
  166. package/src/sast/xxe.js +1 -1
  167. package/src/sca/CLAUDE.md +3 -4
  168. package/src/sca/container.js +35 -3
  169. package/src/sca/dep-confusion.js +9 -1
  170. package/src/sca/sarif-ingest.js +0 -187
@@ -14,7 +14,6 @@ import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
14
14
  import { toPBOM } from '../src/sast/pipeline.js';
15
15
  import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
16
16
  import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
17
- import { ingestAndMerge } from '../src/sca/sarif-ingest.js';
18
17
  import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
19
18
  import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
20
19
  import { applyOverrides, validateOverrides, suppressionReport, renderSuppressionSummary } from '../src/posture/rule-overrides.js';
@@ -28,8 +27,8 @@ import { syncTickets } from '../src/integrations/tickets.js';
28
27
  import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
29
28
  import * as triage from '../src/posture/triage.js';
30
29
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
31
- import { globFiles } from '../src/util/glob.js';
32
30
 
31
+ import { stateDir, statePath } from '../src/posture/state-dir.js';
33
32
  // last-scan.json integrity helpers — implementation in posture/integrity.js
34
33
  // so the MCP server tools can share verification.
35
34
  function _verifyLastScan(body, sigFile) {
@@ -65,6 +64,13 @@ Commands:
65
64
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
66
65
  reset [--yes] [--keep ...] Right-to-delete: wipe accumulated learned state under .agentic-security/ (preserves operator-authored config)
67
66
  rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
67
+ compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
68
+ --list show bundled + BYO frameworks
69
+ --walkthrough <id> auditor narrative for any framework
70
+ --gap only the failing controls
71
+ --format cli|json|md (default cli)
72
+ --fail-on gap exit 1 when a control is failing
73
+ Reads .agentic-security/last-scan.json — run a scan first.
68
74
  version Print version
69
75
  banner [--full] Print the Patch-the-frog mascot + brand lockup
70
76
  harness [path] [--include-home] Multi-harness config audit: scans .claude/,
@@ -103,7 +109,6 @@ Options:
103
109
  --show-threat-model Append the auto-derived STRIDE threat model summary
104
110
  --show-drift Append calibration-drift alarms (overconfidence detection)
105
111
  --sca-reachable-only Only SCA findings where the vulnerable function is reachable
106
- --ingest-sarif <glob> Merge external SARIF into this scan
107
112
  --scorecard Enrich components with OSSF Scorecard scores
108
113
  --no-network Skip OSV/registry queries (offline mode)
109
114
  --pr [ref] Diff-aware: scan only files changed since ref (auto-detects PR base)
@@ -289,21 +294,24 @@ function _bundleSha() {
289
294
  return 'unavailable';
290
295
  }
291
296
 
292
- async function writeMachineOutput(targetAbs, scan, meta, profile) {
293
- const stateDir = path.join(targetAbs, '.agentic-security');
294
- const { isSafeStateDir: _isSafe } = await import('../src/posture/state-dir.js');
295
- if (!_isSafe(stateDir)) {
296
- if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write machine output at ${stateDir}no project marker\n`);
297
+ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
298
+ const stateDirPath = stateDir(targetAbs);
299
+ const { isSafeStateDir: _isSafe, stateWritesEnabled: _writesOn } = await import('../src/posture/state-dir.js');
300
+ // Read-only scan (NON_MUTATING_SCAN_PRD S1). These writes bypass
301
+ // safeWriteState because they are async, so the switch is checked herethe
302
+ // same refusal path the project-root check already uses.
303
+ if (!_writesOn() || !_isSafe(stateDirPath)) {
304
+ if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write machine output at ${stateDirPath} — no project marker\n`);
297
305
  return;
298
306
  }
299
- await fsp.mkdir(stateDir, { recursive: true });
307
+ await fsp.mkdir(stateDirPath, { recursive: true });
300
308
  // Always JSON (used by /security-fix and /security-report).
301
- await fsp.writeFile(path.join(stateDir, 'findings.json'),
309
+ await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
302
310
  JSON.stringify(toJSON(scan, meta), null, 2));
303
- if (profile.profile === 'pro' || profile.machineOutput) {
304
- await fsp.writeFile(path.join(stateDir, 'findings.sarif'),
311
+ if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
312
+ await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
305
313
  JSON.stringify(toSARIF(scan, meta), null, 2));
306
- await fsp.writeFile(path.join(stateDir, 'findings.csv'), toCSV(scan));
314
+ await fsp.writeFile(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
307
315
  }
308
316
  }
309
317
 
@@ -325,6 +333,11 @@ function parseArgs(argv) {
325
333
  }
326
334
 
327
335
  async function cmdScan(args) {
336
+ // NON_MUTATING_SCAN_PRD S1 — a scan is an observation; --no-state makes it one.
337
+ if (args.flags['no-state']) {
338
+ const { setStateWritesEnabled } = await import('../src/posture/state-dir.js');
339
+ setStateWritesEnabled(false);
340
+ }
328
341
  const target = args._[1] || '.';
329
342
  const targetAbs = path.resolve(target);
330
343
  // Load persona profile (R1). Persona-aware defaults flow from here.
@@ -424,13 +437,20 @@ async function cmdScan(args) {
424
437
 
425
438
  const only = args.flags.only;
426
439
  if (only) {
440
+ // Four channels exist (scanner/CLAUDE.md): findings (SAST), secrets,
441
+ // supplyChain (SCA), logicVulns (business-logic). --only sast keeps
442
+ // logicVulns (business-logic is source analysis, part of the SAST
443
+ // pillar); --only sca/secrets must also clear it — it's neither SCA
444
+ // nor secrets, and normalizeFindings()/exitCodeFor() both fold it in,
445
+ // so leaving it meant a business-logic finding leaked into a
446
+ // single-pillar scan's output AND its exit code.
427
447
  if (only === 'sast') { scan.secrets = []; scan.supplyChain = []; }
428
- if (only === 'sca') { scan.findings = []; scan.secrets = []; }
429
- if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; }
448
+ if (only === 'sca') { scan.findings = []; scan.secrets = []; scan.logicVulns = []; }
449
+ if (only === 'secrets') { scan.findings = []; scan.supplyChain = []; scan.logicVulns = []; }
430
450
  }
431
451
 
432
452
  // --set-baseline: save current findings as baseline for future --since-baseline filtering
433
- const baselinePath = path.join(target || '.', '.agentic-security', 'baseline.json');
453
+ const baselinePath = statePath(target || '.', 'baseline.json');
434
454
  if (args.flags['set-baseline']) {
435
455
  const { normalizeFindings } = await import('../src/report/index.js');
436
456
  const baselineIds = new Set(normalizeFindings(scan).map(f => f.stableId || f.id));
@@ -478,18 +498,6 @@ async function cmdScan(args) {
478
498
  // 0.9.0 Feat-18: --scorecard flag enables OSSF Scorecard enrichment
479
499
  if (args.flags['scorecard']) process.env.AGENTIC_SECURITY_SCORECARD = '1';
480
500
 
481
- // 0.7.0 Feat-7: --ingest-sarif <path-or-glob> merges SARIF from external tools (Semgrep,
482
- // gitleaks, Bandit, Trivy, Checkov, etc.) into this scan's findings, deduping by
483
- // fingerprint and tracking provenance via sources[].
484
- if (args.flags['ingest-sarif']) {
485
- const glob = args.flags['ingest-sarif'];
486
- const paths = await globFiles(glob);
487
- if (paths.length) {
488
- const r = ingestAndMerge(scan, paths);
489
- if (process.stderr.isTTY) process.stderr.write(`[ingest] merged ${r.merged} / added ${r.added} findings from ${paths.length} SARIF file(s)\n`);
490
- }
491
- }
492
-
493
501
  // 0.6.0 Feat-1: --sca-reachable-only filters to only SCA findings where the vulnerable
494
502
  // function was confirmed reachable from a route handler.
495
503
  if (args.flags['sca-reachable-only']) {
@@ -550,6 +558,31 @@ async function cmdScan(args) {
550
558
  try { await enrichWithEPSS(scan); } catch {}
551
559
  }
552
560
 
561
+ // Re-run risk-dollars now that both signals it needs are actually
562
+ // available: EPSS (just enriched above — engine.js's own call ran before
563
+ // this, so every finding's epssScore was still unset) and relevanceTier
564
+ // (posture/relevance.js runs last inside runScan/engine.js, so it's
565
+ // populated on `scan.findings` by the time we get here, but wasn't yet
566
+ // when engine.js's own annotateRiskDollars call ran mid-pipeline).
567
+ try {
568
+ const { annotateRiskDollars } = await import('../src/posture/risk-dollars.js');
569
+ annotateRiskDollars(targetAbs, scan.findings || []);
570
+ } catch {}
571
+
572
+ // Re-run composite risk for the same reason as annotateRiskDollars above:
573
+ // engine.js's mid-pipeline annotateCompositeRisk calls ran before
574
+ // enrichWithEPSS (just above) had a chance to set f.exploitedNow, so
575
+ // every finding's 'exploited-now-floor:75' boost was computed against a
576
+ // field that was always undefined at the time — an actively-exploited
577
+ // CVE (EPSS percentile >= 0.95) never got its compositeRisk score raised.
578
+ // annotateCompositeRisk is pure/idempotent (only sets compositeRisk*
579
+ // fields, reads everything else), so re-running it here is safe.
580
+ try {
581
+ const { annotateCompositeRisk } = await import('../src/posture/composite-risk.js');
582
+ annotateCompositeRisk(scan.findings || []);
583
+ annotateCompositeRisk(scan.supplyChain || []);
584
+ } catch {}
585
+
553
586
  // Blast-radius narrative — purely local, always safe to run.
554
587
  if (!args.flags['no-blast-radius']) {
555
588
  try { enrichWithBlastRadius(scan, targetAbs); } catch {}
@@ -606,7 +639,7 @@ async function cmdScan(args) {
606
639
  } catch { /* attestation is metadata; never fail a scan over it */ }
607
640
 
608
641
  // R2: Always emit machine-readable artifacts to .agentic-security/.
609
- await writeMachineOutput(targetAbs, scan, meta, profile);
642
+ await writeMachineOutput(targetAbs, scan, meta, profile, args);
610
643
 
611
644
  const includeSuppressed = !!args.flags['include-suppressed'];
612
645
  let body;
@@ -646,34 +679,72 @@ async function cmdScan(args) {
646
679
  else process.stdout.write(body + '\n');
647
680
 
648
681
  // Persist last scan for /security-fix and /security-report
649
- const { isSafeStateDir: _isSafeStateDir } = await import('../src/posture/state-dir.js');
650
- const stateDir = path.join(path.resolve(target), '.agentic-security');
651
- if (_isSafeStateDir(stateDir)) {
652
- await fsp.mkdir(stateDir, { recursive: true });
653
- const persistedScan = toJSON(scan, meta);
682
+ const { isSafeStateDir: _isSafeStateDir, stateWritesEnabled: _writesOnScan } = await import('../src/posture/state-dir.js');
683
+ const stateDirPath = stateDir(path.resolve(target));
684
+ // S7 (Stage-0 audit): declared here, not with `const` inside the `if`
685
+ // below it used to be block-scoped there while recordScan() further
686
+ // down referenced it unconditionally, throwing a ReferenceError on every
687
+ // single scan (silently swallowed by that call's empty catch{}). streak.json
688
+ // — grades, streak days, achievements — never actually persisted through
689
+ // the real CLI as a result, reproduced live before this fix.
690
+ let persistedScan = null;
691
+ if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
692
+ await fsp.mkdir(stateDirPath, { recursive: true });
693
+ persistedScan = toJSON(scan, meta);
654
694
  // #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
655
695
  // every finding carries an age, SLA breaches can be surfaced, and the fix
656
696
  // loop can report time-to-clean. Best-effort; skipped under --deterministic
657
697
  // so deterministic state stays byte-identical run-to-run.
658
698
  if (!args.flags.deterministic) {
659
699
  try {
660
- const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary } = await import('../src/posture/mttr.js');
700
+ const { stampFindingTimestamps, buildBaselineMap, renderSlaSummary, fingerprintFinding, computeMTTR } = await import('../src/posture/mttr.js');
661
701
  let baselineMap = new Map();
702
+ let prevAll = [];
662
703
  try {
663
- const prev = JSON.parse(await fsp.readFile(path.join(stateDir, 'last-scan.json'), 'utf8'));
704
+ const prev = JSON.parse(await fsp.readFile(path.join(stateDirPath, 'last-scan.json'), 'utf8'));
664
705
  baselineMap = buildBaselineMap(prev);
706
+ // The previous scan's own findings already carry firstSeenAt/lastSeenAt
707
+ // from ITS baseline lookup — exactly the shape computeMTTR needs for
708
+ // whichever of them are no longer present now (i.e. were fixed).
709
+ prevAll = [
710
+ ...(prev?.findings || []), ...(prev?.secrets || []),
711
+ ...(prev?.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
712
+ ];
665
713
  } catch { /* first run — empty baseline, everything is firstSeen now */ }
666
714
  const now = Date.now();
667
715
  stampFindingTimestamps(persistedScan.findings || [], baselineMap, now);
668
716
  stampFindingTimestamps(persistedScan.secrets || [], baselineMap, now);
669
717
  stampFindingTimestamps((persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'), baselineMap, now);
718
+ // #10 — MTTR: which of the previous scan's findings are no longer
719
+ // present now (fixed), and how long each took. computeMTTR itself was
720
+ // fully built and tested but had never had a real caller — the module's
721
+ // own comment calls it "true MTTR," distinct from the open-backlog
722
+ // median-age proxy renderSlaSummary already surfaces below.
723
+ const currentFps = new Set([
724
+ ...(persistedScan.findings || []), ...(persistedScan.secrets || []),
725
+ ...(persistedScan.supplyChain || []).filter(s => s.type === 'vulnerable_dep'),
726
+ ].map(fingerprintFinding));
727
+ const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
728
+ persistedScan.mttr = computeMTTR(removed);
670
729
  // Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
671
730
  const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
672
731
  if (!isJson) {
673
732
  const sla = renderSlaSummary(persistedScan.findings || []);
674
733
  if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
734
+ if (persistedScan.mttr.count > 0) {
735
+ process.stderr.write(`✅ agentic-security: ${persistedScan.mttr.count} finding(s) fixed since last scan, median ${Math.round(persistedScan.mttr.medianDays)}d to remediate\n`);
736
+ }
737
+ }
738
+ } catch (e) {
739
+ // MTTR is best-effort — never block a scan write. But a silent catch
740
+ // here previously hid a real, deterministic CI-only failure (the
741
+ // mttr field went missing on every hosted-CI run, never locally, and
742
+ // nothing explained why) for long enough that it shipped several
743
+ // releases undiagnosed. Surface it — never fail the scan on it.
744
+ if (process.env.AGENTIC_SECURITY_MTTR_DEBUG === '1' || process.env.CI || process.env.GITHUB_ACTIONS) {
745
+ process.stderr.write(`agentic-security: MTTR computation failed (best-effort, scan unaffected): ${(e && e.stack) || e}\n`);
675
746
  }
676
- } catch { /* MTTR is best-effort — never block a scan write */ }
747
+ }
677
748
 
678
749
  // R5 — report the observed time-to-validated-fix distribution from the
679
750
  // fix attempts recorded by `verifyFix`. This is measurement, not
@@ -709,20 +780,27 @@ async function cmdScan(args) {
709
780
  } catch { /* best-effort, offline-degrading — never block a scan */ }
710
781
  }
711
782
  const lastScanBody = JSON.stringify(persistedScan, null, 2);
712
- await fsp.writeFile(path.join(stateDir, 'last-scan.json'), lastScanBody);
783
+ await fsp.writeFile(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
713
784
  try {
714
- await fsp.writeFile(path.join(stateDir, 'last-scan.json.sig'), _signLastScan(lastScanBody));
785
+ await fsp.writeFile(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
715
786
  } catch { /* non-fatal — sig file is best-effort */ }
716
787
  } else {
717
- if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write state at ${stateDir} — no project marker in ${path.resolve(target)}\n`);
788
+ if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write state at ${stateDirPath} — no project marker in ${path.resolve(target)}\n`);
718
789
  }
719
790
 
720
791
  // 0.14.0 — update streak / achievements after every full scan. Suppress
721
792
  // streak side effects when the user only wants raw JSON output (CI piping).
793
+ // Gated on `persistedScan !== null` (i.e. the same _writesOnScan() &&
794
+ // _isSafeStateDir() condition above) — recordScan()'s own isSafeStateDir
795
+ // check alone does not know about --no-state / AGENTIC_SECURITY_NO_STATE,
796
+ // so without this a "read-only, mutates nothing" scan would still write
797
+ // streak.json. This was accidentally masked before the persistedScan
798
+ // scoping fix (recordScan was never reached at all, for the wrong reason);
799
+ // fixing that reachability bug required adding this guard explicitly.
722
800
  try {
723
- const streak = recordScan(stateDir, persistedScan);
801
+ const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
724
802
  // Print celebration / streak line to stderr so it doesn't pollute --format json
725
- if (process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
803
+ if (streak && process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
726
804
  const delta = formatGradeDelta(streak);
727
805
  const line = formatStreakLine(streak);
728
806
  if (delta) process.stderr.write('\n' + delta + '\n');
@@ -798,20 +876,48 @@ async function cmdCi(args) {
798
876
  const packNames = packArg ? (Array.isArray(packArg) ? packArg : String(packArg).split(',')) : [];
799
877
  if (packNames.length) Object.assign(scan, applyPacks(scan, packNames));
800
878
 
801
- // Persist the three CI artifacts.
802
- const stateDir = path.join(targetAbs, '.agentic-security');
803
- const { isSafeStateDir: _isSafeCi } = await import('../src/posture/state-dir.js');
804
- if (!_isSafeCi(stateDir)) {
805
- if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDir} — no project marker\n`);
806
- return;
879
+ // R4 same run attestation cmdScan attaches, computed here too so
880
+ // findings.json (the actual CI evidence artifact) carries the same
881
+ // tamper-evidence digest 'agentic-security scan' does, rather than
882
+ // always shipping attestation: null. Runs after every filter above, so
883
+ // it attests what actually ships. Metadata only never fails the scan.
884
+ try {
885
+ const { computeRunAttestation } = await import('../src/posture/attestation.js');
886
+ const { keyProvenance } = await import('../src/posture/integrity.js');
887
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
888
+ scan.attestation = computeRunAttestation({
889
+ findings: normalizeFindings(scan),
890
+ engineVersion: PKG_VERSION,
891
+ rulesetVersion: effectiveVersion(targetAbs).version,
892
+ bundleSha: _bundleSha(),
893
+ root: targetAbs,
894
+ sign: true,
895
+ });
896
+ if (scan.attestation) scan.attestation.keyProvenance = keyProvenance();
897
+ } catch { /* attestation is metadata; never fail a scan over it */ }
898
+
899
+ // Persist the three CI artifacts — but a refusal to write must NEVER skip
900
+ // the --fail-on evaluation below. S1 (Stage-0 audit, 2026): this used to be
901
+ // a bare `return;` inside the write-guard, so `cmdCi` returned `undefined`
902
+ // and `process.exit(await cmdCi(args))` became `process.exit(undefined)`,
903
+ // which Node treats as exit 0 — a CI pipeline reading only $? would see a
904
+ // passing build whose findings were never evaluated against --fail-on at
905
+ // all. The write and the gate are orthogonal: "we couldn't persist
906
+ // artifacts" is not evidence about "the scan is clean."
907
+ const stateDirPath = stateDir(targetAbs);
908
+ const { isSafeStateDir: _isSafeCi, stateWritesEnabled: _writesOnCi } = await import('../src/posture/state-dir.js');
909
+ const _canWriteCi = _writesOnCi() && _isSafeCi(stateDirPath);
910
+ if (!_canWriteCi) {
911
+ if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
912
+ } else {
913
+ await fsp.mkdir(stateDirPath, { recursive: true });
914
+ await fsp.writeFile(path.join(stateDirPath, 'findings.json'),
915
+ JSON.stringify(toJSON(scan, meta), null, 2));
916
+ await fsp.writeFile(path.join(stateDirPath, 'findings.sarif'),
917
+ JSON.stringify(toSARIF(scan, meta), null, 2));
918
+ await fsp.writeFile(path.join(stateDirPath, 'findings.junit.xml'),
919
+ toJUnit(scan, meta));
807
920
  }
808
- await fsp.mkdir(stateDir, { recursive: true });
809
- await fsp.writeFile(path.join(stateDir, 'findings.json'),
810
- JSON.stringify(toJSON(scan, meta), null, 2));
811
- await fsp.writeFile(path.join(stateDir, 'findings.sarif'),
812
- JSON.stringify(toSARIF(scan, meta), null, 2));
813
- await fsp.writeFile(path.join(stateDir, 'findings.junit.xml'),
814
- toJUnit(scan, meta));
815
921
 
816
922
  const scanCode = exitCodeFor(scan);
817
923
  const findings = normalizeFindings(scan);
@@ -819,7 +925,9 @@ async function cmdCi(args) {
819
925
  for (const f of findings) sev[f.severity] = (sev[f.severity] || 0) + 1;
820
926
  process.stderr.write(
821
927
  `[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
822
- `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n` +
928
+ (_canWriteCi
929
+ ? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
930
+ : `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
823
931
  `[ci] fail-on=${failOn} scan-exit=${scanCode}\n`
824
932
  );
825
933
  // FR-SDLC-9: when --policy <file.rego> is supplied, evaluate against the
@@ -872,7 +980,7 @@ async function cmdAccept(args) {
872
980
  const id = args.flags.finding;
873
981
  if (!id) { console.error('--finding <id> required'); return 4; }
874
982
  const reason = args.flags.reason || 'vibecoded for now';
875
- const lastScanPath = path.join(target, '.agentic-security', 'findings.json');
983
+ const lastScanPath = statePath(target, 'findings.json');
876
984
  if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
877
985
  const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
878
986
  const f = (last.findings || []).find(x => x.id === id);
@@ -930,7 +1038,7 @@ async function cmdTriage(args) {
930
1038
  }
931
1039
  const sub = args._[1];
932
1040
  // Sync first so list reflects the latest scan.
933
- const lastScanPath = path.join(target, '.agentic-security', 'findings.json');
1041
+ const lastScanPath = statePath(target, 'findings.json');
934
1042
  if (fs.existsSync(lastScanPath)) {
935
1043
  const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
936
1044
  triage.syncWithScan(target, last.findings || []);
@@ -1256,7 +1364,7 @@ async function cmdRule(args) {
1256
1364
  async function cmdValidatorCache(args) {
1257
1365
  const sub = args._[1] || 'help';
1258
1366
  const root = path.resolve(args._[2] || '.');
1259
- const cacheDir = path.join(root, '.agentic-security', 'llm-cache');
1367
+ const cacheDir = statePath(root, 'llm-cache');
1260
1368
  if (!fs.existsSync(cacheDir)) {
1261
1369
  console.log(`No validator cache at ${cacheDir}`);
1262
1370
  return 0;
@@ -1319,7 +1427,7 @@ async function cmdValidatorCache(args) {
1319
1427
  // FR-VER-7 fail-closed: any error → cannot-verify, never silent drop.
1320
1428
  async function cmdVerify(args) {
1321
1429
  const scanRoot = path.resolve(args.flags.root || '.');
1322
- const lastScanPath = path.join(scanRoot, '.agentic-security', 'last-scan.json');
1430
+ const lastScanPath = statePath(scanRoot, 'last-scan.json');
1323
1431
  if (!fs.existsSync(lastScanPath)) {
1324
1432
  console.error(`No prior scan found at ${lastScanPath}. Run \`agentic-security scan\` first.`);
1325
1433
  return 4;
@@ -1421,9 +1529,9 @@ async function cmdVerify(args) {
1421
1529
  // --yes to skip the confirmation prompt (for scripted use).
1422
1530
  async function cmdReset(args) {
1423
1531
  const scanRoot = path.resolve(args.flags.root || '.');
1424
- const stateDir = path.join(scanRoot, '.agentic-security');
1425
- if (!fs.existsSync(stateDir)) {
1426
- console.log(`No state to reset at ${stateDir}`);
1532
+ const stateDirPath = stateDir(scanRoot);
1533
+ if (!fs.existsSync(stateDirPath)) {
1534
+ console.log(`No state to reset at ${stateDirPath}`);
1427
1535
  return 0;
1428
1536
  }
1429
1537
  const WIPE = new Set([
@@ -1448,17 +1556,17 @@ async function cmdReset(args) {
1448
1556
  ]);
1449
1557
  const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
1450
1558
  const targets = [];
1451
- for (const entry of await fsp.readdir(stateDir, { withFileTypes: true })) {
1559
+ for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1452
1560
  if (keep.has(entry.name)) continue;
1453
1561
  if (WIPE.has(entry.name) || WIPE_DIRS.has(entry.name)) {
1454
1562
  targets.push({ name: entry.name, dir: entry.isDirectory() });
1455
1563
  }
1456
1564
  }
1457
1565
  if (!targets.length) {
1458
- console.log(`Nothing to reset under ${stateDir}.`);
1566
+ console.log(`Nothing to reset under ${stateDirPath}.`);
1459
1567
  return 0;
1460
1568
  }
1461
- console.log(`agentic-security reset — will remove from ${stateDir}:`);
1569
+ console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
1462
1570
  for (const t of targets) console.log(` ${t.name}${t.dir ? '/' : ''}`);
1463
1571
  console.log('');
1464
1572
  console.log('Preserving operator-authored config: rules.yml, rules/, license-policy.yml, trusted-keys.json, ruleset-version.json');
@@ -1468,7 +1576,7 @@ async function cmdReset(args) {
1468
1576
  return 0;
1469
1577
  }
1470
1578
  for (const t of targets) {
1471
- const p = path.join(stateDir, t.name);
1579
+ const p = path.join(stateDirPath, t.name);
1472
1580
  try {
1473
1581
  if (t.dir) await fsp.rm(p, { recursive: true, force: true });
1474
1582
  else await fsp.rm(p, { force: true });
@@ -1493,7 +1601,7 @@ const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendo
1493
1601
  const HUNT_MAX_FILES = 2000;
1494
1602
 
1495
1603
  async function cmdHunt(args) {
1496
- const scanRoot = path.resolve(args.flags.root || args._[0] || '.');
1604
+ const scanRoot = path.resolve(args.flags.root || args._[1] || '.');
1497
1605
  const { listFiles } = await import('../src/util/glob.js');
1498
1606
  const { buildProjectIR } = await import('../src/ir/index.js');
1499
1607
  const { runDiscovery } = await import('../src/discovery/index.js');
@@ -1517,19 +1625,43 @@ async function cmdHunt(args) {
1517
1625
  // Prior scan and triage verdicts feed the judge so a hunt does not re-report
1518
1626
  // what the rule engine already found or what a human already dismissed.
1519
1627
  let priorScan = null, triageFeedback = null;
1520
- try { priorScan = JSON.parse(fs.readFileSync(path.join(scanRoot, '.agentic-security', 'last-scan.json'), 'utf8')); } catch {}
1521
- try { triageFeedback = JSON.parse(fs.readFileSync(path.join(scanRoot, '.agentic-security', 'triage-feedback.json'), 'utf8')); } catch {}
1628
+ try { priorScan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); } catch {}
1629
+ try { triageFeedback = JSON.parse(fs.readFileSync(statePath(scanRoot, 'triage-feedback.json'), 'utf8')); } catch {}
1522
1630
 
1523
1631
  const lenses = args.flags.lens ? String(args.flags.lens).split(',').map(s => s.trim()).filter(Boolean) : undefined;
1632
+ const intFlag = (name) => (args.flags[name] ? parseInt(args.flags[name], 10) : undefined);
1633
+ const numFlag = (name) => (args.flags[name] ? Number(args.flags[name]) : undefined);
1524
1634
  const report = await runDiscovery(
1525
1635
  { perFileIR: perFile, callGraph, fileContents, priorScan, triageFeedback },
1526
- { lenses, maxAreas: args.flags['max-areas'] ? parseInt(args.flags['max-areas'], 10) : undefined },
1636
+ {
1637
+ lenses,
1638
+ maxAreas: intFlag('max-areas'),
1639
+ // PRD Phase 0 / C3. Defaults live in discovery/index.js so the library is
1640
+ // bounded even when driven by something other than this CLI.
1641
+ maxLlmCalls: intFlag('max-llm-calls'),
1642
+ maxWallMs: intFlag('max-wall-ms'),
1643
+ maxCandidates: intFlag('max-candidates'),
1644
+ maxCostUsd: numFlag('max-cost-usd'),
1645
+ // PRD C4 — persist and consult cross-run memory.
1646
+ scanRoot: args.flags['no-memory'] ? undefined : scanRoot,
1647
+ // PRD C2 — a comma-separated endpoint list turns on consensus.
1648
+ endpoints: args.flags.endpoints,
1649
+ costPerCallUsd: numFlag('cost-per-call-usd'),
1650
+ },
1527
1651
  );
1528
1652
 
1529
1653
  const c = report.coverage;
1530
1654
  console.log('');
1531
1655
  console.log(`Discovery — ${rels.length} file(s), ${c.areasPlanned} focus area(s), ${c.lensesPerArea} lens(es) each`);
1532
1656
  console.log(` areas hunted: ${c.areasHunted}/${c.areasPlanned} (fully: ${c.areasFullyHunted}) degraded runs: ${c.degradedRuns}/${report.runs.length}`);
1657
+ console.log(` budget: ${c.llmCalls}/${c.maxLlmCalls} LLM calls${c.budgetExhausted ? ' ← EXHAUSTED, run is incomplete' : ''}` +
1658
+ `${c.candidatesCapped ? ` ${c.candidatesCapped} candidate(s) capped` : ''}` +
1659
+ `${c.callsPerFinding !== null && c.callsPerFinding !== undefined ? ` ${c.callsPerFinding} calls/finding` : ''}`);
1660
+ if (c.priorRuns !== null && c.priorRuns !== undefined) {
1661
+ console.log(` memory: ${c.priorRuns} prior run(s)` +
1662
+ `${c.rememberedRefutals ? `, ${c.rememberedRefutals} candidate(s) skipped as already refuted` : ''}`);
1663
+ if (c.nextWave?.summary) console.log(` next wave: ${c.nextWave.summary}`);
1664
+ }
1533
1665
  console.log(` confirmation: ${JSON.stringify(c.confirmedByTier)} panels: ${c.panelsRun} (undecided ${c.undecidedPanels})`);
1534
1666
  console.log('');
1535
1667
 
@@ -1548,6 +1680,23 @@ async function cmdHunt(args) {
1548
1680
  `${report.refutedCandidates.length} refuted by the panel)`);
1549
1681
  }
1550
1682
 
1683
+ // PRD C5 — variant analysis. A confirmed finding is rarely alone: the same
1684
+ // root cause is usually copy-pasted, and the siblings often do not trip a
1685
+ // rule. sweepRootCauses already does this with exact accounting for scan
1686
+ // findings; discovery findings simply never reached it until now.
1687
+ if (report.fresh.length && !args.flags['no-variants']) {
1688
+ try {
1689
+ const { sweepRootCauses, formatSweepLedger } = await import('../src/posture/root-cause-sweep.js');
1690
+ const sweep = sweepRootCauses(report.fresh, fileContents, { confirmedOnly: false });
1691
+ const ledger = formatSweepLedger(sweep);
1692
+ if (ledger) {
1693
+ console.log('');
1694
+ console.log('Variant sweep (siblings of the findings above):');
1695
+ console.log(` ${ledger}`);
1696
+ }
1697
+ } catch { /* the sweep is additive; never let it sink the report */ }
1698
+ }
1699
+
1551
1700
  // Degradation is part of the output. A half-failed hunt must never read as a
1552
1701
  // clean one, so every reason is printed rather than summarised away.
1553
1702
  if (c.reasons.length) {
@@ -1567,6 +1716,228 @@ async function cmdHunt(args) {
1567
1716
  return 0;
1568
1717
  }
1569
1718
 
1719
+ // PRD D2 — emit signed, per-finding evidence bundles, and verify them.
1720
+ //
1721
+ // `attest` needs our private key. `verify-attestation` needs ONLY a public key,
1722
+ // which is the entire point: a buyer or auditor checks the artefact without ever
1723
+ // having had access to anything of ours.
1724
+ /**
1725
+ * `compliance` — framework assessment from the CLI.
1726
+ *
1727
+ * Reads the LAST SCAN rather than running a new one. A compliance answer is a
1728
+ * statement about a scan that happened, and silently re-scanning here would
1729
+ * make the number depend on when you asked rather than on what was measured —
1730
+ * the same class of confusion as a benchmark that mutates its own corpus. If
1731
+ * there is no scan to read, this says so and exits non-zero instead of
1732
+ * assessing an empty project, which would report controls as satisfied on the
1733
+ * strength of having looked at nothing.
1734
+ */
1735
+ async function cmdCompliance(args) {
1736
+ const scanRoot = path.resolve(args.flags.root || '.');
1737
+ const fmt = String(args.flags.format || 'cli');
1738
+ const {
1739
+ assessPrivacyFramework, PRIVACY_FRAMEWORK_ID, BUCKETS,
1740
+ } = await import('../src/posture/privacy-framework.js');
1741
+ const { listFrameworks, loadFramework, evaluateFramework, renderWalkthrough } =
1742
+ await import('../src/posture/auditor-walkthrough.js');
1743
+
1744
+ if (args.flags.list) {
1745
+ const fws = listFrameworks(scanRoot);
1746
+ if (fmt === 'json') { console.log(JSON.stringify(fws, null, 2)); return 0; }
1747
+ for (const f of fws) console.log(` ${f.id.padEnd(20)} ${f.name} [${f.source}]`);
1748
+ return 0;
1749
+ }
1750
+
1751
+ let scan;
1752
+ try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
1753
+ catch {
1754
+ console.error('No .agentic-security/last-scan.json — run `agentic-security scan .` first.');
1755
+ console.error('Refusing to assess an unscanned project: every control would report as');
1756
+ console.error('unassessed, which is correct but useless, and one flag away from looking clean.');
1757
+ return 2;
1758
+ }
1759
+ // The engine records this two ways depending on the emit path; take either.
1760
+ scan.filesScanned = scan._scanMeta?.filesScanned ?? scan.scanned?.files ?? 0;
1761
+
1762
+ const wt = args.flags.walkthrough;
1763
+ if (wt && wt !== true) {
1764
+ const fw = loadFramework(scanRoot, String(wt));
1765
+ if (!fw) {
1766
+ console.error(`Unknown framework "${wt}". Try --list.`);
1767
+ return 2;
1768
+ }
1769
+ console.log(renderWalkthrough(fw, evaluateFramework(scanRoot, fw, scan), {}));
1770
+ return 0;
1771
+ }
1772
+
1773
+ // Default mode is --privacy: it is the only framework with a remediation
1774
+ // layer, so it is the only one where a CLI exit code means anything.
1775
+ const r = assessPrivacyFramework(scanRoot, scan);
1776
+ if (!r) { console.error(`Framework ${PRIVACY_FRAMEWORK_ID} could not be loaded.`); return 2; }
1777
+
1778
+ const gapsOnly = !!args.flags.gap;
1779
+ if (fmt === 'json') {
1780
+ console.log(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2));
1781
+ } else if (fmt === 'md') {
1782
+ console.log(fs.readFileSync(statePath(scanRoot, 'privacy-framework.md'), 'utf8'));
1783
+ } else {
1784
+ console.log(`\n${r.frameworkName}\n`);
1785
+ console.log(` ${r.interpretation}\n`);
1786
+ for (const b of BUCKETS) {
1787
+ const rows = r.controls.filter(c => c.bucket === b);
1788
+ if (!rows.length || (gapsOnly && b !== 'gap')) continue;
1789
+ console.log(` ${b} (${rows.length})`);
1790
+ for (const c of rows) {
1791
+ console.log(` ${c.id.padEnd(11)} ${c.summary.slice(0, 84)}`);
1792
+ const f = r.findings.find(x => x.id === `privacy-framework:${c.id}`);
1793
+ if (f) console.log(` → ${f.remediation}`);
1794
+ }
1795
+ console.log('');
1796
+ }
1797
+ console.log(' A control that is "manual" or "engine-gap" is NOT evidence of compliance.');
1798
+ console.log(' This organizes scanner evidence; a licensed assessor owns the attestation.\n');
1799
+ }
1800
+
1801
+ // --fail-on gap is opt-in. A compliance opinion becomes a build failure only
1802
+ // when someone asks for it; defaulting to non-zero would break every pipeline
1803
+ // that adds this command to see the report.
1804
+ if (args.flags['fail-on'] === 'gap' && r.summary.gap > 0) return 1;
1805
+ return 0;
1806
+ }
1807
+
1808
+ async function cmdAttest(args) {
1809
+ const scanRoot = path.resolve(args.flags.root || '.');
1810
+ const {
1811
+ ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
1812
+ } = await import('../src/posture/evidence-bundle.js');
1813
+
1814
+ let scan;
1815
+ try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
1816
+ catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
1817
+
1818
+ const findings = scan.findings || [];
1819
+ const wanted = args.flags.id;
1820
+ const subset = wanted ? findings.filter(f => f.id === wanted || f.stableId === wanted) : findings;
1821
+ if (!subset.length) {
1822
+ console.error(wanted ? `No finding matching "${wanted}".` : 'No findings to attest.');
1823
+ return 2;
1824
+ }
1825
+
1826
+ const kp = ensureKeyPair();
1827
+ if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
1828
+
1829
+ const outDir = statePath(scanRoot, 'attestations');
1830
+ fs.mkdirSync(outDir, { recursive: true });
1831
+ const meta = {
1832
+ engineVersion: scan.engineVersion || null,
1833
+ rulesetVersion: scan.rulesetVersion || null,
1834
+ bundleSha: scan.bundleSha || null,
1835
+ commit: scan.commit || null,
1836
+ };
1837
+
1838
+ let n = 0;
1839
+ for (const f of subset) {
1840
+ const bundle = signEvidenceBundle(buildEvidenceBundle(f, meta), kp.privateKeyPem);
1841
+ const name = `${(f.stableId || f.id || `finding-${n}`)}.json`.replace(/[^\w.-]/g, '_');
1842
+ fs.writeFileSync(path.join(outDir, name), JSON.stringify(bundle, null, 2) + '\n');
1843
+ n++;
1844
+ }
1845
+ console.log(`Signed ${n} evidence bundle(s) → ${path.relative(scanRoot, outDir)}/`);
1846
+ console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
1847
+ console.log('');
1848
+ console.log('A bundle proves its contents are unmodified since signing. It does NOT');
1849
+ console.log('prove the finding is real — read evidence.proofTier for that.');
1850
+ return 0;
1851
+ }
1852
+
1853
+ // R4/D2 correctness follow-up: attestation.js ships TWO independent
1854
+ // verifiers — verifyEvidenceBundle (per-finding, Ed25519, self-contained —
1855
+ // only needs a public key) and verifyRunAttestation (whole-run, per-install
1856
+ // HMAC, NOT self-contained — needs the caller to also supply the finding
1857
+ // set to re-derive against). This CLI command only ever called the first;
1858
+ // the second had zero production callers anywhere in the repo (confirmed by
1859
+ // grep — only test/attestation.test.js called it). Auto-detect which
1860
+ // artifact was handed in and dispatch to the matching verifier instead of
1861
+ // silently misinterpreting a run-attestation as a (structurally different)
1862
+ // evidence bundle.
1863
+ function _asRunAttestation(obj) {
1864
+ if (obj && typeof obj.digest === 'string' && obj.canonicalisation) return obj;
1865
+ if (obj && obj.attestation && typeof obj.attestation.digest === 'string') return obj.attestation;
1866
+ return null;
1867
+ }
1868
+
1869
+ // Verifies a run attestation the ONLY way it can be verified: by re-deriving
1870
+ // the digest from a fresh scan and comparing. Unlike an evidence bundle
1871
+ // (self-contained, just needs a public key), a run attestation is a claim
1872
+ // about a FINDING SET, not a standalone signed blob — "does scanning this
1873
+ // codebase right now reproduce the exact digest an earlier run attested"
1874
+ // is the actual, meaningful question this command answers.
1875
+ async function cmdVerifyRunAttestation(attestation, args) {
1876
+ const projectPath = path.resolve(args.flags.against || '.');
1877
+ if (!fs.existsSync(projectPath)) {
1878
+ console.error(`--against path does not exist: ${projectPath}`);
1879
+ return 2;
1880
+ }
1881
+ console.log(`Re-scanning ${projectPath} to check it reproduces the attested digest...`);
1882
+ const { runScan } = await import('../src/runScan.js');
1883
+ const { normalizeFindings } = await import('../src/report/index.js');
1884
+ const { effectiveVersion } = await import('../src/posture/ruleset-version.js');
1885
+ const { verifyRunAttestation } = await import('../src/posture/attestation.js');
1886
+ const { scan } = await runScan(projectPath);
1887
+ const r = verifyRunAttestation(attestation, {
1888
+ findings: normalizeFindings(scan),
1889
+ engineVersion: PKG_VERSION,
1890
+ rulesetVersion: effectiveVersion(projectPath).version,
1891
+ bundleSha: _bundleSha(),
1892
+ root: projectPath,
1893
+ });
1894
+ if (!r.ok) {
1895
+ console.error(`✗ INVALID — ${r.reason}`);
1896
+ return 1;
1897
+ }
1898
+ console.log('✓ VALID — a fresh scan of this project reproduces the attested digest exactly.');
1899
+ console.log('');
1900
+ console.log(` digest: ${attestation.digest}`);
1901
+ console.log(` findingCount: ${attestation.findingCount}`);
1902
+ console.log(` proves: ${attestation.proves}`);
1903
+ console.log(` does NOT prove: ${attestation.doesNotProve}`);
1904
+ return 0;
1905
+ }
1906
+
1907
+ async function cmdVerifyAttestation(args) {
1908
+ const { verifyEvidenceBundle, keyPaths } = await import('../src/posture/evidence-bundle.js');
1909
+ // `args._[0]` is the command name itself — the same convention cmdScan uses.
1910
+ const file = args.flags.bundle || args._[1];
1911
+ if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
1912
+
1913
+ let bundle;
1914
+ try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
1915
+ catch (e) { console.error(`Could not read bundle: ${e.message}`); return 2; }
1916
+
1917
+ const runAttestation = _asRunAttestation(bundle);
1918
+ if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
1919
+
1920
+ const keyFile = args.flags['public-key'] || keyPaths().publicKey;
1921
+ let publicKeyPem = null;
1922
+ try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
1923
+ catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
1924
+
1925
+ const r = verifyEvidenceBundle(bundle, publicKeyPem);
1926
+ if (!r.ok) {
1927
+ console.error(`✗ INVALID — ${r.reason}`);
1928
+ return 1;
1929
+ }
1930
+ const f = bundle.finding || {};
1931
+ console.log('✓ VALID — the bundle is exactly what the signer attested.');
1932
+ console.log('');
1933
+ console.log(` ${f.severity ? `[${f.severity}] ` : ''}${f.vuln || '(no title)'} ${f.file || '?'}:${f.line ?? '?'}`);
1934
+ console.log(` cwe ${f.cwe || 'n/a'} parser ${f.parser || 'n/a'} proof tier ${bundle.evidence?.proofTier || 'n/a'}`);
1935
+ console.log('');
1936
+ console.log(` proves: ${bundle.proves}`);
1937
+ console.log(` does NOT prove: ${bundle.doesNotProve}`);
1938
+ return 0;
1939
+ }
1940
+
1570
1941
  async function cmdRuleSynth(args) {
1571
1942
  const scanRoot = path.resolve(args.flags.root || '.');
1572
1943
  const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
@@ -1603,7 +1974,7 @@ async function cmdPacks(args) {
1603
1974
  async function cmdDigest(args) {
1604
1975
  const target = path.resolve(args._[1] || '.');
1605
1976
  const profile = loadProfile(target);
1606
- const lastScanPath = path.join(target, '.agentic-security', 'findings.json');
1977
+ const lastScanPath = statePath(target, 'findings.json');
1607
1978
  if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found.'); return 4; }
1608
1979
  const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
1609
1980
  const findings = (last.findings || []).filter(f => f.severity === 'critical' || f.severity === 'high');
@@ -1631,7 +2002,7 @@ async function cmdFix(args) {
1631
2002
  const isApply = !!args.flags.apply;
1632
2003
  const scanRoot = path.resolve(args.flags.root || '.');
1633
2004
  if (!id) { console.error('--finding <id> required'); return 4; }
1634
- const lastScanPath = path.join(scanRoot, '.agentic-security', 'last-scan.json');
2005
+ const lastScanPath = statePath(scanRoot, 'last-scan.json');
1635
2006
  if (!fs.existsSync(lastScanPath)) { console.error('No prior scan found. Run `agentic-security scan` first.'); return 4; }
1636
2007
  const lastScanBody = await fsp.readFile(lastScanPath, 'utf8');
1637
2008
  const sigVerified = _verifyLastScan(lastScanBody, lastScanPath + '.sig');
@@ -1860,6 +2231,9 @@ async function main() {
1860
2231
  case 'verify': process.exit(await cmdVerify(args));
1861
2232
  case 'reset': process.exit(await cmdReset(args));
1862
2233
  case 'hunt': process.exit(await cmdHunt(args));
2234
+ case 'compliance': process.exit(await cmdCompliance(args));
2235
+ case 'attest': process.exit(await cmdAttest(args));
2236
+ case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
1863
2237
  case 'rule-synth': process.exit(await cmdRuleSynth(args));
1864
2238
  case 'digest': process.exit(await cmdDigest(args));
1865
2239
  case 'setup': process.exit(await cmdSetup(args));
@@ -1882,7 +2256,7 @@ async function main() {
1882
2256
  const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
1883
2257
  if (result.ok) {
1884
2258
  try {
1885
- const dir = path.join(projectDir, '.agentic-security');
2259
+ const dir = stateDir(projectDir);
1886
2260
  fs.mkdirSync(dir, { recursive: true });
1887
2261
  fs.writeFileSync(path.join(dir, 'cache-telemetry.json'),
1888
2262
  JSON.stringify({ updatedAt: new Date().toISOString(), metrics: result.metrics, leaks: result.leaks }, null, 2));