@clear-capabilities/agentic-security-scanner 0.142.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 +399 -0
  2. package/bin/agentic-security.js +530 -54
  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 +83 -18
  93. package/src/report/oscal.js +635 -0
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -39,6 +39,17 @@ function writeStdout(s) {
39
39
  }
40
40
  }
41
41
  import { toJSON, toMarkdown, toSARIF, toSTIX, toCSV, toJUnit, toCLI, toCLIByProfile, toShipVerdict, toProTable, toHTML, toSummary, toVex, exitCodeFor, normalizeFindings } from '../src/report/index.js';
42
+ import { toOSCAL } from '../src/report/oscal.js';
43
+
44
+ // Formats whose output is a machine artifact, not something a human reads in a
45
+ // terminal. Three separate copies of this list used to be spelled out inline —
46
+ // the MTTR line, the fix-duration line and the streak line — and each new
47
+ // format had to be added to all three or it silently got human chatter
48
+ // interleaved into its stdout/stderr. One list, one place to update.
49
+ const MACHINE_FORMATS = new Set([
50
+ 'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom',
51
+ ]);
52
+ function isMachineFormat(fmt) { return MACHINE_FORMATS.has(String(fmt)); }
42
53
  import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
43
54
  import { toPBOM } from '../src/sast/pipeline.js';
44
55
  import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
@@ -50,14 +61,16 @@ import { listPacks, loadPack, applyPacks } from '../src/posture/rule-packs.js';
50
61
  import { writeLockfile, verifyLockfile, makeDeterministic, isDeterministic } from '../src/posture/deterministic.js';
51
62
  import { enrichWithEPSS } from '../src/posture/epss.js';
52
63
  import { enrichWithBlastRadius } from '../src/posture/blast-radius.js';
53
- import { applyCustomRules, runRuleTests, loadCustomRules } from '../src/posture/custom-rules.js';
54
- 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';
55
67
  import { syncTickets } from '../src/integrations/tickets.js';
56
68
  import { decide as decideNextAction, explain as explainDecision } from '../src/posture/router.js';
57
69
  import * as triage from '../src/posture/triage.js';
58
70
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
59
71
 
60
72
  import { stateDir, statePath } from '../src/posture/state-dir.js';
73
+ import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
61
74
  // last-scan.json integrity helpers — implementation in posture/integrity.js
62
75
  // so the MCP server tools can share verification.
63
76
  function _verifyLastScan(body, sigFile) {
@@ -92,12 +105,28 @@ Commands:
92
105
  validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
93
106
  verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
94
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
95
119
  rule-synth [--dry-run] Auto-synthesise suppression rules from repeated FP verdicts (proposes — does not activate)
96
120
  compliance [--privacy] Assess the last scan against NIST Privacy Framework 1.1
97
121
  --list show bundled + BYO frameworks
98
122
  --walkthrough <id> auditor narrative for any framework
123
+ --report <id> synonym for --walkthrough
99
124
  --gap only the failing controls
100
- --format cli|json|md (default cli)
125
+ --format cli|json|md|oscal (default cli)
126
+ --format oscal NIST OSCAL assessment-results.
127
+ Controls the engine could not
128
+ decide carry NO finding — see
129
+ docs/OSCAL.md.
101
130
  --fail-on gap exit 1 when a control is failing
102
131
  Reads .agentic-security/last-scan.json — run a scan first.
103
132
  version Print version
@@ -113,13 +142,16 @@ Commands:
113
142
  Options:
114
143
  --profile vibecoder|pro Override profile for this run
115
144
  --only sast|sca|secrets Limit scan to one pillar
116
- --format <fmt> cli | json | md | sarif | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
145
+ --format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
117
146
  --pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
118
147
  --baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
119
148
  --fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
120
149
  --fail-on-new (ci) block ONLY on findings this PR introduced vs
121
150
  the baseline ref — never the pre-existing backlog
122
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)
123
155
  --columns standard|mitre|capec|owasp Pro-mode column set (default: standard)
124
156
  --confidence <0..1> Override per-profile confidence threshold
125
157
  --firehose Show ALL findings (ignore confidence threshold)
@@ -584,6 +616,18 @@ async function cmdScan(args) {
584
616
  }
585
617
  } catch {}
586
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
+
587
631
  // EPSS exploit-prediction enrichment (skipped under --no-network / --deterministic).
588
632
  // Bumps severity on actively-exploited CVEs so they sort to the top.
589
633
  if (!args.flags['no-epss'] && !isDeterministic() && !noNet) {
@@ -678,6 +722,10 @@ async function cmdScan(args) {
678
722
  if (format === 'json') body = JSON.stringify(toJSON(scan, meta, { includeSuppressed }), null, 2);
679
723
  else if (format === 'md' || format === 'markdown') body = toMarkdown(scan, meta);
680
724
  else if (format === 'sarif') body = JSON.stringify(toSARIF(scan, meta), null, 2);
725
+ // OSCAL assessment-results. Observations and risks only, never findings — an
726
+ // OSCAL finding is a statement about a control, and a source scan reviews no
727
+ // control catalog. See src/report/oscal.js.
728
+ else if (format === 'oscal') body = JSON.stringify(toOSCAL(scan, meta), null, 2);
681
729
  else if (format === 'stix') body = JSON.stringify(toSTIX(scan, meta), null, 2);
682
730
  else if (format === 'junit') body = toJUnit(scan, meta);
683
731
  else if (format === 'csv') body = toCSV(scan);
@@ -759,7 +807,7 @@ async function cmdScan(args) {
759
807
  const removed = prevAll.filter(f => !currentFps.has(fingerprintFinding(f)));
760
808
  persistedScan.mttr = computeMTTR(removed);
761
809
  // Surface the SLA-breach line on human-readable formats (not JSON/CI pipes).
762
- const isJson = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
810
+ const isJson = isMachineFormat(format);
763
811
  if (!isJson) {
764
812
  const sla = renderSlaSummary(persistedScan.findings || []);
765
813
  if (sla) process.stderr.write(`⏰ agentic-security: ${sla}\n`);
@@ -789,7 +837,7 @@ async function cmdScan(args) {
789
837
  const fixMetrics = fixDurationReport(path.resolve(target));
790
838
  if (fixMetrics.attempts > 0) {
791
839
  persistedScan.fixMetrics = fixMetrics;
792
- const isJsonFmt = format === 'json' || format === 'sarif' || format === 'cyclonedx' || format === 'sbom' || format === 'spdx' || format === 'vex' || format === 'openvex' || format === 'pbom' || format === 'aibom';
840
+ const isJsonFmt = isMachineFormat(format);
793
841
  const line = renderFixDurationSummary(fixMetrics);
794
842
  if (!isJsonFmt && line) process.stderr.write(`🔧 agentic-security: ${line}\n`);
795
843
  }
@@ -832,7 +880,7 @@ async function cmdScan(args) {
832
880
  try {
833
881
  const streak = persistedScan !== null ? recordScan(stateDirPath, persistedScan) : null;
834
882
  // Print celebration / streak line to stderr so it doesn't pollute --format json
835
- if (streak && process.stderr.isTTY && format !== 'json' && format !== 'sarif') {
883
+ if (streak && process.stderr.isTTY && !isMachineFormat(format)) {
836
884
  const delta = formatGradeDelta(streak);
837
885
  const line = formatStreakLine(streak);
838
886
  if (delta) process.stderr.write('\n' + delta + '\n');
@@ -955,8 +1003,23 @@ async function cmdCi(args) {
955
1003
  const findings = normalizeFindings(scan);
956
1004
  const sev = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
957
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`;
958
1020
  process.stderr.write(
959
1021
  `[ci] ${findings.length} findings — ${sev.critical} critical · ${sev.high} high · ${sev.medium} medium · ${sev.low} low\n` +
1022
+ _healthLine +
960
1023
  (_canWriteCi
961
1024
  ? `[ci] artifacts: .agentic-security/findings.{json,sarif,junit.xml}\n`
962
1025
  : `[ci] artifacts: NOT written (state writes refused — set AGENTIC_SECURITY_DEBUG=1 for the reason)\n`) +
@@ -980,6 +1043,23 @@ async function cmdCi(args) {
980
1043
  }
981
1044
  process.stderr.write(`[ci] policy gate PASSED (${r.runner}, 0 denials)\n`);
982
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`);
983
1063
  // R24 (PRD §5): PR-native net-new gate. With --fail-on-new and a baseline
984
1064
  // ref, block ONLY on findings this PR INTRODUCED (vs the base ref), never on
985
1065
  // the pre-existing backlog — the posture teams actually leave enabled.
@@ -1122,6 +1202,17 @@ async function cmdTriage(args) {
1122
1202
  console.log(` Open: critical=${t.openBySev.critical} high=${t.openBySev.high} medium=${t.openBySev.medium} low=${t.openBySev.low}`);
1123
1203
  if (t.medianMttrDays != null) console.log(` MTTR median: ${t.medianMttrDays.toFixed(1)} days`);
1124
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
+ }
1125
1216
  return 0;
1126
1217
  }
1127
1218
  console.error('triage list | assign <id> <assignee> | transition <id> <state> | trend [--since N]');
@@ -1566,57 +1657,263 @@ async function cmdReset(args) {
1566
1657
  console.log(`No state to reset at ${stateDirPath}`);
1567
1658
  return 0;
1568
1659
  }
1569
- const WIPE = new Set([
1570
- 'validator-metrics.json',
1571
- 'triage-feedback.json',
1572
- 'scan-history.json',
1573
- 'last-scan.json',
1574
- 'last-scan.json.sig',
1575
- 'shadow-findings.json',
1576
- 'mcp-audit.log',
1577
- 'hook-throttle.json',
1578
- 'tickets.json',
1579
- 'streak.json',
1580
- 'findings.json',
1581
- 'findings.sarif',
1582
- 'findings.csv',
1583
- ]);
1584
- const WIPE_DIRS = new Set([
1585
- 'llm-cache',
1586
- 'fix-history',
1587
- 'fix-plans',
1588
- ]);
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));
1589
1668
  const keep = new Set((args.flags.keep || '').split(',').filter(Boolean));
1590
- const targets = [];
1591
- for (const entry of await fsp.readdir(stateDirPath, { withFileTypes: true })) {
1592
- if (keep.has(entry.name)) continue;
1593
- if (WIPE.has(entry.name) || WIPE_DIRS.has(entry.name)) {
1594
- 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
+ }
1595
1688
  }
1596
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
+ });
1597
1705
  if (!targets.length) {
1598
- 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
+ }
1599
1710
  return 0;
1600
1711
  }
1601
- console.log(`agentic-security reset — will remove from ${stateDirPath}:`);
1602
- 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
+ }
1603
1717
  console.log('');
1604
- 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';
1605
1749
  if (!args.flags.yes) {
1606
1750
  console.log('');
1607
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
+ }));
1608
1760
  return 0;
1609
1761
  }
1762
+ const outcomes = [];
1610
1763
  for (const t of targets) {
1611
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 };
1612
1766
  try {
1613
1767
  if (t.dir) await fsp.rm(p, { recursive: true, force: true });
1614
1768
  else await fsp.rm(p, { force: true });
1769
+ outcomes.push({ ...base, status: 'deleted' });
1615
1770
  } catch (e) {
1616
1771
  console.error(`reset: failed to remove ${p}: ${e.message}`);
1772
+ outcomes.push({ ...base, status: 'failed', error: e.message });
1773
+ }
1774
+ }
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}`);
1617
1877
  }
1878
+ return 0;
1618
1879
  }
1619
- console.log(`Reset ${targets.length} item(s). Operator-authored config preserved.`);
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`.');
1620
1917
  return 0;
1621
1918
  }
1622
1919
 
@@ -1791,14 +2088,27 @@ async function cmdCompliance(args) {
1791
2088
  // The engine records this two ways depending on the emit path; take either.
1792
2089
  scan.filesScanned = scan._scanMeta?.filesScanned ?? scan.scanned?.files ?? 0;
1793
2090
 
1794
- const wt = args.flags.walkthrough;
2091
+ // `--report <fw>` is accepted as a synonym for `--walkthrough <fw>`: the
2092
+ // slash-command surface has always spelled it that way, and it previously
2093
+ // reached the frameworks only through an inlined node call in commands/
2094
+ // rather than through this CLI. One code path, two spellings.
2095
+ const wt = args.flags.walkthrough || args.flags.report;
1795
2096
  if (wt && wt !== true) {
1796
2097
  const fw = loadFramework(scanRoot, String(wt));
1797
2098
  if (!fw) {
1798
2099
  console.error(`Unknown framework "${wt}". Try --list.`);
1799
2100
  return 2;
1800
2101
  }
1801
- console.log(renderWalkthrough(fw, evaluateFramework(scanRoot, fw, scan), {}));
2102
+ const evaluation = evaluateFramework(scanRoot, fw, scan);
2103
+ if (fmt === 'oscal') {
2104
+ const { toOSCALCompliance, complianceRowsFromEvaluation } = await import('../src/report/oscal.js');
2105
+ writeStdout(JSON.stringify(
2106
+ toOSCALCompliance(fw, complianceRowsFromEvaluation(evaluation), { startedAt: scan._scanMeta?.startedAt }),
2107
+ null, 2) + '\n');
2108
+ return 0;
2109
+ }
2110
+ if (fmt === 'json') { writeStdout(JSON.stringify(evaluation, null, 2) + '\n'); return 0; }
2111
+ console.log(renderWalkthrough(fw, evaluation, {}));
1802
2112
  return 0;
1803
2113
  }
1804
2114
 
@@ -1808,6 +2118,19 @@ async function cmdCompliance(args) {
1808
2118
  if (!r) { console.error(`Framework ${PRIVACY_FRAMEWORK_ID} could not be loaded.`); return 2; }
1809
2119
 
1810
2120
  const gapsOnly = !!args.flags.gap;
2121
+ if (fmt === 'oscal') {
2122
+ // The privacy assessment's own bucket model, not evaluateFramework's — see
2123
+ // complianceRowsFromPrivacy for why `engine-gap` must not become a control
2124
+ // failure. `--gap` is deliberately NOT applied here: an OSCAL document that
2125
+ // silently omitted the satisfied controls would understate what was
2126
+ // reviewed, and reviewed-controls would then disagree with the findings.
2127
+ const { toOSCALCompliance, complianceRowsFromPrivacy } = await import('../src/report/oscal.js');
2128
+ const fwMeta = loadFramework(scanRoot, PRIVACY_FRAMEWORK_ID) || { id: PRIVACY_FRAMEWORK_ID, name: r.frameworkName };
2129
+ writeStdout(JSON.stringify(
2130
+ toOSCALCompliance(fwMeta, complianceRowsFromPrivacy(r), { startedAt: scan._scanMeta?.startedAt }),
2131
+ null, 2) + '\n');
2132
+ return args.flags['fail-on'] === 'gap' && r.summary.gap > 0 ? 1 : 0;
2133
+ }
1811
2134
  if (fmt === 'json') {
1812
2135
  writeStdout(JSON.stringify(gapsOnly ? { ...r, controls: r.controls.filter(c => c.bucket === 'gap') } : r, null, 2) + '\n');
1813
2136
  } else if (fmt === 'md') {
@@ -1943,8 +2266,15 @@ async function cmdVerifyAttestation(args) {
1943
2266
  if (!file) { console.error('Usage: agentic-security verify-attestation <bundle.json|last-scan.json> [--public-key <path>] [--against <project-path>]'); return 2; }
1944
2267
 
1945
2268
  let bundle;
1946
- try { bundle = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8')); }
1947
- 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; }
1948
2278
 
1949
2279
  const runAttestation = _asRunAttestation(bundle);
1950
2280
  if (runAttestation) return cmdVerifyRunAttestation(runAttestation, args);
@@ -1954,6 +2284,24 @@ async function cmdVerifyAttestation(args) {
1954
2284
  try { publicKeyPem = fs.readFileSync(path.resolve(keyFile), 'utf8'); }
1955
2285
  catch { console.error(`Could not read public key at ${keyFile}. Pass --public-key <path>.`); return 2; }
1956
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
+
1957
2305
  const r = verifyEvidenceBundle(bundle, publicKeyPem);
1958
2306
  if (!r.ok) {
1959
2307
  console.error(`✗ INVALID — ${r.reason}`);
@@ -1970,6 +2318,84 @@ async function cmdVerifyAttestation(args) {
1970
2318
  return 0;
1971
2319
  }
1972
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
+
1973
2399
  async function cmdRuleSynth(args) {
1974
2400
  const scanRoot = path.resolve(args.flags.root || '.');
1975
2401
  const { synthesizeRules } = await import('../src/posture/rule-synthesis.js');
@@ -2053,11 +2479,15 @@ async function cmdFix(args) {
2053
2479
  return 0;
2054
2480
  }
2055
2481
 
2056
- // Both --preview and --apply require an actual replacement to operate on.
2057
- // For now we accept either f.fix.replacement (full new file content) or
2058
- // f.fix.replaceLine (single-line replacement). Anything else falls back
2059
- // to the template output and tells the user to run the security-fixer subagent.
2060
- 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; }
2061
2491
  if (!fs.existsSync(absFile)) { console.error(`File not found: ${absFile}`); return 4; }
2062
2492
  const originalContent = await fsp.readFile(absFile, 'utf8');
2063
2493
  let newContent = null;
@@ -2081,16 +2511,56 @@ async function cmdFix(args) {
2081
2511
  return 0;
2082
2512
  }
2083
2513
 
2084
- // --apply. Premortem 4R-8: pass stableId from the engine directly so the
2085
- // recover() cross-check is robust against line-number drift (f.id is
2086
- // `${file}:${line}:${rule}` and rotates when the user edits the file).
2087
- const entry = await applyFix({
2088
- scanRoot, file: f.file, originalContent, newContent,
2089
- findingId: f.id, stableId: f.stableId || null,
2090
- 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,
2091
2544
  });
2092
- 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})`);
2093
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
+ }
2094
2564
  console.log(` revert with: agentic-security undo`);
2095
2565
  return 0;
2096
2566
  }
@@ -2262,10 +2732,16 @@ async function main() {
2262
2732
  case 'validator-cache': process.exit(await cmdValidatorCache(args));
2263
2733
  case 'verify': process.exit(await cmdVerify(args));
2264
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));
2265
2739
  case 'hunt': process.exit(await cmdHunt(args));
2266
2740
  case 'compliance': process.exit(await cmdCompliance(args));
2267
2741
  case 'attest': process.exit(await cmdAttest(args));
2268
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));
2269
2745
  case 'rule-synth': process.exit(await cmdRuleSynth(args));
2270
2746
  case 'digest': process.exit(await cmdDigest(args));
2271
2747
  case 'setup': process.exit(await cmdSetup(args));