@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.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 (162) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +2 -2
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/{271.index.js → 2271.index.js} +2 -2
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +5 -5
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +5 -5
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +2 -2
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +3 -3
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +560 -189
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +12 -5
  72. package/src/dataflow/CLAUDE.md +1 -1
  73. package/src/dataflow/catalog.js +42 -0
  74. package/src/dataflow/orm-write-catalog.js +175 -0
  75. package/src/engine.js +92 -1
  76. package/src/ir/CLAUDE.md +1 -0
  77. package/src/ir/chrome-probe.mjs +150 -0
  78. package/src/ir/parser-js.js +94 -7
  79. package/src/lineage/CLAUDE.md +1203 -0
  80. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  81. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  82. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  83. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  84. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  85. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  86. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  87. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  88. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  89. package/src/lineage/classification.js +56 -0
  90. package/src/lineage/coverage.js +658 -0
  91. package/src/lineage/cross-repo-link.js +107 -0
  92. package/src/lineage/dataflow-graph.schema.json +184 -0
  93. package/src/lineage/decision-story.js +206 -0
  94. package/src/lineage/drift-policy.js +279 -0
  95. package/src/lineage/driver.js +135 -0
  96. package/src/lineage/engine.js +992 -0
  97. package/src/lineage/export-briefing.js +628 -0
  98. package/src/lineage/export-csv.js +62 -0
  99. package/src/lineage/export-json.js +238 -0
  100. package/src/lineage/export-privacy.js +258 -0
  101. package/src/lineage/federation-loader.js +111 -0
  102. package/src/lineage/field-identity.js +78 -0
  103. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  104. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  105. package/src/lineage/flow-grade.js +221 -0
  106. package/src/lineage/governance-edit.js +169 -0
  107. package/src/lineage/graph-builder.js +1114 -0
  108. package/src/lineage/graph-diff.js +431 -0
  109. package/src/lineage/graph-snapshot.js +180 -0
  110. package/src/lineage/handling-analyzer.js +168 -0
  111. package/src/lineage/ids.js +349 -0
  112. package/src/lineage/impact-assessment.js +76 -0
  113. package/src/lineage/impact-engine.js +268 -0
  114. package/src/lineage/index.js +281 -0
  115. package/src/lineage/language-coverage-tiers.js +58 -0
  116. package/src/lineage/obligation-mapping.js +126 -0
  117. package/src/lineage/obligation-predicates.js +235 -0
  118. package/src/lineage/observation-adapters.js +282 -0
  119. package/src/lineage/observation-correlation.js +622 -0
  120. package/src/lineage/observation-store.js +497 -0
  121. package/src/lineage/path-query.js +410 -0
  122. package/src/lineage/path-store.js +400 -0
  123. package/src/lineage/protection.js +53 -0
  124. package/src/lineage/recipient-profile.js +192 -0
  125. package/src/lineage/recipient-registry.js +394 -0
  126. package/src/lineage/redact-graph.js +224 -0
  127. package/src/lineage/remediation.js +417 -0
  128. package/src/lineage/resolve-destination.js +91 -0
  129. package/src/lineage/runtime-observation.js +464 -0
  130. package/src/lineage/scenario-diff.js +84 -0
  131. package/src/lineage/scenario-engine.js +251 -0
  132. package/src/lineage/scenario.js +101 -0
  133. package/src/lineage/schema.js +167 -0
  134. package/src/lineage/sink-registry.js +427 -0
  135. package/src/lineage/source-registry.js +357 -0
  136. package/src/lineage/source-seeding.js +212 -0
  137. package/src/lineage/summaries.js +590 -0
  138. package/src/lineage/transform-catalog.js +397 -0
  139. package/src/lineage/transit-protection.js +150 -0
  140. package/src/lineage/validate.js +285 -0
  141. package/src/mcp/CLAUDE.md +7 -1
  142. package/src/mcp/dataflow-tools.js +160 -0
  143. package/src/mcp/server.js +1 -1
  144. package/src/mcp/tools.js +2 -1
  145. package/src/pipeline/scan-health.js +19 -1
  146. package/src/posture/CLAUDE.md +19 -0
  147. package/src/posture/artifact-registry.js +52 -0
  148. package/src/posture/auditor-walkthrough.js +76 -0
  149. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  150. package/src/posture/obligation-evidence-pack.js +202 -0
  151. package/src/posture/remediation-ledger.js +337 -0
  152. package/src/server/CLAUDE.md +47 -0
  153. package/src/server/graph-loader.js +141 -0
  154. package/src/server/http-server.js +325 -0
  155. package/src/server/routes.js +129 -0
  156. package/src/server/security.js +111 -0
  157. package/src/server/static-assets.js +139 -0
  158. package/dist/11.index.js +0 -353
  159. package/dist/259.index.js +0 -975
  160. package/dist/317.index.js +0 -300
  161. package/dist/609.index.js +0 -741
  162. package/dist/838.index.js +0 -152
@@ -4,12 +4,14 @@
4
4
  import * as fs from 'node:fs';
5
5
  import * as fsp from 'node:fs/promises';
6
6
  import * as path from 'node:path';
7
+ import * as crypto from 'node:crypto';
7
8
  import { createRequire } from 'node:module';
8
9
  const __require = createRequire(import.meta.url);
9
10
  const PKG_VERSION = __require('../package.json').version;
10
11
  import { signLastScan as _signLastScan, verifyLastScan as _verifyLastScanShared } from '../src/posture/integrity.js';
11
12
  import { isProvenanceHealthy, sanitizeForTerminal } from '../src/posture/provenance/schema.js';
12
13
  import { runScan } from '../src/runScan.js';
14
+ import { persistGraphSnapshot, loadSnapshots, loadSnapshot, mostRecentPriorSnapshot } from '../src/lineage/graph-snapshot.js';
13
15
 
14
16
  // Every command is dispatched as `process.exit(await cmdX(args))`, and
15
17
  // process.exit() does NOT flush an asynchronous stdout. stdout is asynchronous
@@ -70,7 +72,7 @@ import { decide as decideNextAction, explain as explainDecision } from '../src/p
70
72
  import * as triage from '../src/posture/triage.js';
71
73
  import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
72
74
 
73
- import { stateDir, statePath } from '../src/posture/state-dir.js';
75
+ import { stateDir, statePath, withStateWritesDisabled } from '../src/posture/state-dir.js';
74
76
  import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
75
77
  // last-scan.json integrity helpers — implementation in posture/integrity.js
76
78
  // so the MCP server tools can share verification.
@@ -139,6 +141,74 @@ Commands:
139
141
  scan-baseline --current <f> --previous <f>
140
142
  Finding-level diff between two scan JSON outputs.
141
143
  Reports added / removed / changed findings.
144
+ explore [path] [--port <n>] [--keep-open]
145
+ Start a local, read-only server over an
146
+ already-scanned lineage graph (run
147
+ AGENTIC_SECURITY_LINEAGE_DEEP=1 scan first)
148
+ dataflow export [path] --format png|pdf|svg|json|csv|html|dpia|ropa|briefing|recipients|coverage --output <file>
149
+ Export the already-scanned lineage graph.
150
+ --view architecture|privacy|trace|inventory (default: architecture;
151
+ png/pdf/svg only — no-op + warning for json/csv/html/dpia/ropa/briefing/recipients/coverage)
152
+ --size standard|2x AC-23 pinned PNG sizes (default: standard)
153
+ --width <n> --height <n> custom PNG size, <= 20000 (mutually exclusive with --size)
154
+ --no-redact include unredacted content (json/html only; no-op + warning for csv/dpia/ropa/briefing/recipients/coverage)
155
+ --filter <path.json> {nodeIds,edgeIds} to scope the export (no-op + warning for csv/coverage)
156
+ --audience board|ciso|privacy|compliance|regulator|technical
157
+ (default: technical; briefing only — wording/verbosity, never facts)
158
+ dataflow scenario apply [path] --operations <file.json> --output <file> [--format json|markdown]
159
+ Simulate a hypothetical architecture change against the
160
+ already-scanned lineage graph — never mutates the real scan.
161
+ --privacy-sink-policy <path-to-json> re-evaluate each touched
162
+ flow's policyVerdict under this policy file
163
+ --environment <name> environment context for --privacy-sink-policy
164
+ dataflow impact assess [path] --target <canonical-id> --output <file> [--format json|markdown]
165
+ Blast-radius impact assessment over the already-scanned lineage
166
+ graph — read-only, never mutates anything, never re-runs a scan.
167
+ --target node:*|edge:*|flow:*|data:* the compromised entity's id
168
+ governance propose-edit [path] --patch <file.json> [--output <file>] [--yes] [--base-digest <hex>]
169
+ Propose a validated, reviewable edit to
170
+ recipient-profiles.json. Without --yes,
171
+ previews the diff and writes nothing.
172
+ federate declare [path] --local-node <node-id> --remote-graph <file>
173
+ --remote-node <node-id> [--repository <label>]
174
+ [--relationship data_flow] [--rationale <text>]
175
+ [--output <file>] [--yes] [--base-digest <hex>]
176
+ Declare a CrossRepoLink between a node in the
177
+ current locally-scanned graph and a node in a
178
+ remote graph export (dataflow export --format
179
+ json). Without --yes, previews and writes nothing.
180
+ federate list [path] [--output <file>]
181
+ List every declared cross-repo link, reporting
182
+ whether each side still resolves.
183
+ remediation open [path] --assessment <impact-report.json> --owner <id> --due <YYYY-MM-DD>
184
+ --control <text> --required-evidence <flowId,...>
185
+ [--id <itemId>] [--snapshot <commit>] [--allow-manual-attestation]
186
+ [--output <file>] [--yes]
187
+ Open a new remediation work item against an incident
188
+ snapshot. Without --yes, previews the folded item and
189
+ writes nothing to the append-only ledger.
190
+ remediation update [path] --id <itemId> --state <in_progress|awaiting_verification>
191
+ [--base-event <hash>] [--output <file>] [--yes]
192
+ Advance a remediation item's state. --state verified is
193
+ always refused — see remediation verify (AC-31).
194
+ remediation verify [path] --id <itemId> [--against <commit>]
195
+ [--manual-attestation --approver <id> --reason <text> [--author <id>]]
196
+ [--base-event <hash>] [--output <file>] [--yes]
197
+ Verify a remediation item against a fresh lineage scan (or an
198
+ explicitly-permitted manual attestation). AC-31: marking work
199
+ complete never sets "verified" — only this command can.
200
+ remediation reopen-check [path] [--drift-policy <file>] [--against <commit>]
201
+ [--output <file>] [--yes]
202
+ Reopen verified items whose control regressed (drift-policy match,
203
+ or an affected flow removed/changed in the latest diff). No
204
+ --base-event: this can append events across many items at once.
205
+ remediation accept-risk [path] --id <itemId> --approver <id> --reason <text>
206
+ --scope <text> --expires <YYYY-MM-DD> [--author <id>]
207
+ [--base-event <hash>] [--output <file>] [--yes]
208
+ Record an accepted-risk exception, gated on the operator's
209
+ authorized-approvers registry when one exists.
210
+ remediation list [path] [--format json|markdown] [--output <file>]
211
+ List every remediation item folded from the ledger.
142
212
 
143
213
  Options:
144
214
  --profile vibecoder|pro Override profile for this run
@@ -957,6 +1027,12 @@ async function cmdScan(args) {
957
1027
  if (_writesOnScan() && _isSafeStateDir(stateDirPath)) {
958
1028
  await fsp.mkdir(stateDirPath, { recursive: true });
959
1029
  persistedScan = toJSON(scan, meta);
1030
+ // Sub-project E, increment 5: the lineage graph gets its OWN artifact
1031
+ // file (below), never duplicated inside last-scan.json — a
1032
+ // DataFlowGraph v1 document is a separate, potentially large artifact,
1033
+ // and embedding it a second time here would bloat the file every other
1034
+ // consumer of last-scan.json already reads in full.
1035
+ delete persistedScan.lineageGraph;
960
1036
  // #10 — MTTR: stamp firstSeenAt/lastSeenAt/ageDays from the PREVIOUS scan so
961
1037
  // every finding carries an age, SLA breaches can be surfaced, and the fix
962
1038
  // loop can report time-to-clean. Best-effort; skipped under --deterministic
@@ -1050,6 +1126,32 @@ async function cmdScan(args) {
1050
1126
  try {
1051
1127
  await fsp.writeFile(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
1052
1128
  } catch { /* non-fatal — sig file is best-effort */ }
1129
+ // Sub-project E, increment 5: persist the lineage graph as its own
1130
+ // artifact, mirroring last-scan.json's own write+sign pattern exactly
1131
+ // — signLastScan/verifyLastScan are fully generic (an arbitrary string
1132
+ // body + an explicit sig-file path, no filename baked in), confirmed by
1133
+ // reading posture/integrity.js directly, so no new signing mechanism
1134
+ // is introduced. Written only when a graph actually exists — an
1135
+ // ordinary scan (AGENTIC_SECURITY_LINEAGE_DEEP unset) writes nothing
1136
+ // new here at all.
1137
+ if (scan.lineageGraph) {
1138
+ try {
1139
+ const lineageBody = JSON.stringify(scan.lineageGraph, null, 2);
1140
+ await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
1141
+ try {
1142
+ await fsp.writeFile(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
1143
+ } catch { /* non-fatal — sig file is best-effort, same precedent as last-scan.json.sig above */ }
1144
+ } catch { /* non-fatal — the lineage artifact write is best-effort and must never block a scan */ }
1145
+ // M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): ADDITIVELY
1146
+ // persist the SAME graph into the commit-keyed snapshot history
1147
+ // (.agentic-security/lineage-snapshots/<HEAD>.json), never replacing
1148
+ // the single-current-graph artifact written just above. Best-effort,
1149
+ // same precedent as the write above — a snapshot-history failure must
1150
+ // never block a scan.
1151
+ try {
1152
+ persistGraphSnapshot(scan.lineageGraph, targetAbs);
1153
+ } catch { /* non-fatal — snapshot history is best-effort and must never block a scan */ }
1154
+ }
1053
1155
  } else {
1054
1156
  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`);
1055
1157
  }
@@ -2231,6 +2333,48 @@ async function cmdHunt(args) {
2231
2333
  return 0;
2232
2334
  }
2233
2335
 
2336
+ // Data Flow Explorer M4 sub-project 6c's final whole-branch review,
2337
+ // findings F2/F3/F5-followup: every caller that discards a
2338
+ // loadFreshLineageGraph result for staleness must also disclose WHY,
2339
+ // distinguishing "no graph exists at all" from "a graph exists but isn't
2340
+ // fresh for this scan" (never letting the latter print the former's
2341
+ // "no lineage graph found" message, which would be actively false — a
2342
+ // graph DOES exist, it just isn't trusted). Shared by cmdAttest's
2343
+ // --obligations branch and cmdCompliance's --walkthrough/--report branch
2344
+ // rather than duplicated a third time, since this predicate's own
2345
+ // duplication was already flagged once (F3) as unacceptable for a safety
2346
+ // disclosure.
2347
+ function _lineageStalenessNoteLines(graphIsFresh, loaded, scan, subject) {
2348
+ if (graphIsFresh) return null;
2349
+ const capSubject = subject.charAt(0).toUpperCase() + subject.slice(1);
2350
+ if (!loaded.ok) {
2351
+ if (loaded.reason === 'missing') {
2352
+ return [
2353
+ 'NOTE: no lineage graph found (AGENTIC_SECURITY_LINEAGE_DEEP=1 was not set on',
2354
+ `the scan that produced last-scan.json) — ${subject} reads "unknown", by design.`,
2355
+ ];
2356
+ }
2357
+ return [
2358
+ `NOTE: lineage graph could not be loaded (${loaded.reason}: ${loaded.message})`,
2359
+ `— ${subject} reads "unknown", by design.`,
2360
+ ];
2361
+ }
2362
+ if (scan.scanHealth?.lineageAnalysis?.failure) {
2363
+ return [
2364
+ 'NOTE: a lineage graph exists on disk, but this scan\'s own lineage build',
2365
+ `failed (${scan.scanHealth.lineageAnalysis.failure}) — ignoring the stale graph`,
2366
+ 'rather than asserting a fact about code it may no longer reflect.',
2367
+ `${capSubject} reads "unknown", by design.`,
2368
+ ];
2369
+ }
2370
+ return [
2371
+ 'NOTE: a lineage graph exists on disk from an earlier deep scan, but this scan',
2372
+ 'did not run one (AGENTIC_SECURITY_LINEAGE_DEEP=1 was not set) — ignoring the',
2373
+ 'stale graph rather than asserting a fact about code it may no longer reflect.',
2374
+ `${capSubject} reads "unknown", by design.`,
2375
+ ];
2376
+ }
2377
+
2234
2378
  // PRD D2 — emit signed, per-finding evidence bundles, and verify them.
2235
2379
  //
2236
2380
  // `attest` needs our private key. `verify-attestation` needs ONLY a public key,
@@ -2285,7 +2429,21 @@ async function cmdCompliance(args) {
2285
2429
  console.error(`Unknown framework "${wt}". Try --list.`);
2286
2430
  return 2;
2287
2431
  }
2288
- const evaluation = evaluateFramework(scanRoot, fw, scan);
2432
+ // Final whole-branch review finding R2 (sub-project 6c), fixed here
2433
+ // rather than merely disclosed: `scan.lineageGraph` is NEVER present
2434
+ // in last-scan.json (see the identical, already-fixed defect in
2435
+ // cmdAttest's --obligations branch above — engine.js only assigns
2436
+ // scan.lineageGraph in-process; bin/agentic-security.js strips it
2437
+ // before persisting, since the real graph gets its own signed
2438
+ // artifact). --walkthrough has read scan.lineageGraph directly since
2439
+ // it shipped in sub-project 6b, so every graph: mapping has always
2440
+ // read "unknown" through this command regardless of whether
2441
+ // AGENTIC_SECURITY_LINEAGE_DEEP=1 was ever set. Same shared, freshness
2442
+ // -checked loader --obligations uses (loadFreshLineageGraph's own
2443
+ // header has the full staleness reasoning).
2444
+ const { loadFreshLineageGraph } = await import('../src/server/graph-loader.js');
2445
+ const { graph: wtGraph, fresh: wtGraphIsFresh, loaded: wtLoaded } = loadFreshLineageGraph(scanRoot, scan);
2446
+ const evaluation = evaluateFramework(scanRoot, fw, { ...scan, lineageGraph: wtGraph });
2289
2447
  if (fmt === 'oscal') {
2290
2448
  const { toOSCALCompliance, complianceRowsFromEvaluation } = await import('../src/report/oscal.js');
2291
2449
  writeStdout(JSON.stringify(
@@ -2295,6 +2453,13 @@ async function cmdCompliance(args) {
2295
2453
  }
2296
2454
  if (fmt === 'json') { writeStdout(JSON.stringify(evaluation, null, 2) + '\n'); return 0; }
2297
2455
  console.log(renderWalkthrough(fw, evaluation, {}));
2456
+ // Scoped re-review finding (non-blocking, fixed): a stale/missing
2457
+ // graph used to be discarded here with no disclosure at all — the
2458
+ // same F2 gap already fixed for `attest --obligations`, unfixed on
2459
+ // this sibling surface. Only the plain narrative gets the NOTE
2460
+ // (never --format json/oscal, which must stay machine-parseable).
2461
+ const wtNote = _lineageStalenessNoteLines(wtGraphIsFresh, wtLoaded, scan, 'every graph: mapping in this walkthrough');
2462
+ if (wtNote) { console.log(''); for (const line of wtNote) console.log(line); }
2298
2463
  return 0;
2299
2464
  }
2300
2465
 
@@ -2395,6 +2560,106 @@ async function cmdAttest(args) {
2395
2560
  return 0;
2396
2561
  }
2397
2562
 
2563
+ // Data Flow Explorer M4 sub-project 6c: a signed evidence pack for a
2564
+ // Regulatory Obligation Overlay framework evaluation (FR-504). Unlike
2565
+ // `--provenance` (a broad "attest everything" toggle with an optional
2566
+ // narrowing id), `--obligations` genuinely REQUIRES a framework id — an
2567
+ // evidence pack only exists per framework, and no "attest every
2568
+ // framework" loop exists anywhere in this codebase.
2569
+ if (args.flags.obligations) {
2570
+ const frameworkId = args.flags.obligations === true ? null : args.flags.obligations;
2571
+ if (!frameworkId) {
2572
+ const { listFrameworks } = await import('../src/posture/auditor-walkthrough.js');
2573
+ console.error('Usage: agentic-security attest --obligations <framework-id>');
2574
+ console.error(`Bundled frameworks: ${listFrameworks(scanRoot).map((f) => f.id).join(', ')}`);
2575
+ return 2;
2576
+ }
2577
+ const { loadFramework, evaluateFramework } = await import('../src/posture/auditor-walkthrough.js');
2578
+ const {
2579
+ buildObligationEvidencePack, signObligationEvidencePack, ensureKeyPair,
2580
+ } = await import('../src/posture/obligation-evidence-pack.js');
2581
+ // Task-2 review finding (blocking): scan.lineageGraph is NEVER present
2582
+ // in last-scan.json — bin/agentic-security.js's own scan persistence
2583
+ // deletes it before writing (`delete persistedScan.lineageGraph`,
2584
+ // Sub-project E increment 5's own comment: "the lineage graph gets its
2585
+ // OWN artifact file, never duplicated inside last-scan.json"). The real
2586
+ // graph lives at .agentic-security/lineage-graph.json, signed
2587
+ // separately — cmdExplore already established the correct way to read
2588
+ // it. Final-review finding B2/F1 (blocking): a stale copy of that file
2589
+ // must never be trusted just because it exists — see
2590
+ // loadFreshLineageGraph's own header for the full reasoning
2591
+ // (shared with compliance --walkthrough below, which had the
2592
+ // identical gap).
2593
+ const { loadFreshLineageGraph } = await import('../src/server/graph-loader.js');
2594
+
2595
+ let scan;
2596
+ try { scan = JSON.parse(fs.readFileSync(statePath(scanRoot, 'last-scan.json'), 'utf8')); }
2597
+ catch { console.error('No .agentic-security/last-scan.json — run a scan first.'); return 2; }
2598
+
2599
+ const fw = loadFramework(scanRoot, frameworkId);
2600
+ if (!fw) { console.error(`Unknown framework: ${frameworkId}`); return 2; }
2601
+
2602
+ const { graph, fresh: graphIsFresh, loaded } = loadFreshLineageGraph(scanRoot, scan);
2603
+
2604
+ const evaluation = evaluateFramework(scanRoot, fw, { ...scan, lineageGraph: graph });
2605
+ const pack = buildObligationEvidencePack({
2606
+ graph,
2607
+ framework: fw,
2608
+ evaluation,
2609
+ scanHealth: scan.scanHealth ?? null,
2610
+ // Final whole-branch review finding B1 (blocking): these three
2611
+ // fields live under scan.attestation (set by computeRunAttestation
2612
+ // at scan time — bin/agentic-security.js's own `scan.attestation =
2613
+ // computeRunAttestation({...})` call), never at the top level of
2614
+ // last-scan.json — reading scan.engineVersion etc. directly always
2615
+ // read undefined, leaving reproducibility permanently null through
2616
+ // the real CLI. NOTE: the sibling attest (finding-bundle) and
2617
+ // attest --provenance code paths a little above this branch read
2618
+ // the SAME wrong top-level fields and carry the identical,
2619
+ // pre-existing bug — real, disclosed, but out of this sub-project's
2620
+ // scope to fix (they predate this branch's own diff).
2621
+ engineVersion: scan.attestation?.engineVersion || null,
2622
+ rulesetVersion: scan.attestation?.rulesetVersion || null,
2623
+ bundleSha: scan.attestation?.bundleSha || null,
2624
+ });
2625
+
2626
+ const kp = ensureKeyPair();
2627
+ if (kp.created) console.error(`Generated a new signing key at ${kp.privateKey} (public: ${kp.publicKey}).`);
2628
+
2629
+ const outDir = statePath(scanRoot, 'attestations');
2630
+ fs.mkdirSync(outDir, { recursive: true });
2631
+ const signed = signObligationEvidencePack(pack, kp.privateKeyPem);
2632
+ const name = `evidence-pack-${frameworkId}.json`.replace(/[^\w.-]/g, '_');
2633
+ fs.writeFileSync(path.join(outDir, name), JSON.stringify(signed, null, 2) + '\n');
2634
+
2635
+ console.log(`Signed evidence pack for ${frameworkId} → ${path.relative(scanRoot, path.join(outDir, name))}`);
2636
+ console.log(`Public key (share this with whoever verifies): ${kp.publicKey}`);
2637
+ console.log('');
2638
+ // Final whole-branch review finding R1 (recommended, fixed): the
2639
+ // summary line named only unknown/manual/acceptedExceptions — an
2640
+ // auditor scanning this output for a real, assessed gap_detected fact
2641
+ // (the state a graph: predicate actually failing produces) saw "0 0 0"
2642
+ // and nothing else, the same "a real gap can hide under an all-clear
2643
+ // summary" failure class auditor-walkthrough.js's own ⚠️ prefix
2644
+ // already exists to prevent (sub-project 6b's own final review).
2645
+ const gapCount = pack.facts.filter((f) => f.state === 'gap_detected').length;
2646
+ console.log(` facts: ${pack.facts.length} ${gapCount > 0 ? '⚠️ gaps: ' + gapCount + ' ' : ''}unknown: ${pack.unknownItems.length} manual: ${pack.manualItems.length} accepted exceptions: ${pack.acceptedExceptions.length}`);
2647
+ console.log('');
2648
+ console.log('A pack proves its contents are unmodified since signing. It does NOT');
2649
+ console.log('certify compliance — read the pack\'s own `disclaimer` field.');
2650
+ // Final whole-branch review finding F2 (recommended, fixed): this
2651
+ // NOTE previously keyed on `!loaded.ok`, so the B2/F1 staleness guard
2652
+ // above could silently discard a real graph with NO disclosure at
2653
+ // all — an operator saw "unknown: 1" and nothing explaining why, even
2654
+ // though a signed graph genuinely existed on disk. Keyed on
2655
+ // `!graphIsFresh` instead (via the shared helper above
2656
+ // cmdCompliance), so every path that discards a graph also explains
2657
+ // why.
2658
+ const obNote = _lineageStalenessNoteLines(graphIsFresh, loaded, scan, 'every graph: fact in this pack');
2659
+ if (obNote) { console.log(''); for (const line of obNote) console.log(line); }
2660
+ return 0;
2661
+ }
2662
+
2398
2663
  const {
2399
2664
  ensureKeyPair, buildEvidenceBundle, signEvidenceBundle,
2400
2665
  } = await import('../src/posture/evidence-bundle.js');
@@ -2566,6 +2831,31 @@ async function cmdVerifyAttestation(args) {
2566
2831
  return 0;
2567
2832
  }
2568
2833
 
2834
+ // Data Flow Explorer M4 sub-project 6c: an obligation evidence pack
2835
+ // (schema: agentic-security/obligation-evidence-pack@1) is a fifth
2836
+ // distinct shape this same command can be handed — same auto-detection
2837
+ // chain as the ComplianceEvidence/provenance branches above, dispatched
2838
+ // by schema marker. Must be checked BEFORE the fallback
2839
+ // verifyEvidenceBundle() call below, which assumes evidence-bundle.js's
2840
+ // own shape and would misinterpret an evidence pack.
2841
+ const { verifyObligationEvidencePack, OBLIGATION_EVIDENCE_PACK_SCHEMA } = await import('../src/posture/obligation-evidence-pack.js');
2842
+ if (bundle.schema === OBLIGATION_EVIDENCE_PACK_SCHEMA) {
2843
+ const or = verifyObligationEvidencePack(bundle, publicKeyPem);
2844
+ if (!or.ok) { console.error(`✗ INVALID — ${or.reason}`); return 1; }
2845
+ console.log('✓ VALID — the evidence pack is exactly what the signer produced.');
2846
+ console.log('');
2847
+ console.log(` framework: ${bundle.framework?.id} version: ${bundle.framework?.version}`);
2848
+ // Final whole-branch review finding R1 (recommended, fixed) — same
2849
+ // "a real gap must never hide under an all-clear summary" guard as
2850
+ // the signing side above.
2851
+ const bundleGapCount = (bundle.facts ?? []).filter((f) => f?.state === 'gap_detected').length;
2852
+ console.log(` facts: ${bundle.facts?.length ?? 0} ${bundleGapCount > 0 ? '⚠️ gaps: ' + bundleGapCount + ' ' : ''}unknown: ${bundle.unknownItems?.length ?? 0} manual: ${bundle.manualItems?.length ?? 0} accepted exceptions: ${bundle.acceptedExceptions?.length ?? 0}`);
2853
+ if (bundle.graphDigest) console.log(` graph digest: ${bundle.graphDigest}`);
2854
+ console.log('');
2855
+ console.log(` ${bundle.disclaimer}`);
2856
+ return 0;
2857
+ }
2858
+
2569
2859
  const r = verifyEvidenceBundle(bundle, publicKeyPem);
2570
2860
  if (!r.ok) {
2571
2861
  console.error(`✗ INVALID — ${r.reason}`);
@@ -2974,76 +3264,3225 @@ Do not suppress anything you are not certain is a false positive. When in doubt,
2974
3264
  return 0;
2975
3265
  }
2976
3266
 
2977
- async function main() {
2978
- const args = parseArgs(process.argv.slice(2));
2979
- const cmd = args._[0];
3267
+ // `agentic-security explore [path] [--port <n>] [--keep-open]` — Milestone 3,
3268
+ // sub-project Server, increment 1. Starts a local, read-only, loopback-only
3269
+ // HTTP server over an already-built, signed lineage graph
3270
+ // (.agentic-security/lineage-graph.json + .sig, written by a prior scan
3271
+ // with AGENTIC_SECURITY_LINEAGE_DEEP=1). Never triggers a scan itself
3272
+ // (Decision 2 of the scoping doc) — genuinely read-only end to end.
3273
+ //
3274
+ // Argument parsing mirrors cmdScan's own shape: scan root from args._[1]
3275
+ // (defaulting to cwd), flags parsed via args.flags. On a failed
3276
+ // loadSignedGraph, prints ONE of four distinct, clear error messages (see
3277
+ // graph-loader.js) and returns a non-zero exit code WITHOUT ever starting
3278
+ // the server. On success, starts the server and prints the URL + session
3279
+ // token to stdout — the ONLY place the token is ever displayed; it is
3280
+ // never written to a file and never logged again after this one print.
3281
+ //
3282
+ // The returned promise resolves (with exit code 0) only once the server
3283
+ // itself closes (idle-timeout auto-stop, or an external kill) — matching
3284
+ // every other cmdX(args) function's "resolves to an exit code" contract
3285
+ // while keeping the process alive for as long as the server is listening.
3286
+ async function cmdExplore(args) {
3287
+ const target = args._[1] || '.';
3288
+ const targetAbs = path.resolve(target);
3289
+
3290
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
3291
+ const loaded = loadSignedGraph(targetAbs);
3292
+ if (!loaded.ok) {
3293
+ process.stderr.write(`agentic-security explore: ${loaded.message}\n`);
3294
+ return 1;
3295
+ }
3296
+
3297
+ let port = 0;
3298
+ if (args.flags.port !== undefined) {
3299
+ if (typeof args.flags.port !== 'string' || !/^\d+$/.test(args.flags.port)) {
3300
+ process.stderr.write(`agentic-security explore: invalid --port value "${args.flags.port}" — must be a non-negative integer.\n`);
3301
+ return 1;
3302
+ }
3303
+ port = Number(args.flags.port);
3304
+ if (port > 65535) {
3305
+ process.stderr.write(`agentic-security explore: invalid --port value "${args.flags.port}" — must be <= 65535.\n`);
3306
+ return 1;
3307
+ }
3308
+ }
3309
+ const keepOpen = !!args.flags['keep-open'];
3310
+
3311
+ const { createExploreServer } = await import('../src/server/http-server.js');
3312
+ const { generateSessionToken } = await import('../src/server/security.js');
3313
+ const sessionToken = generateSessionToken();
3314
+
3315
+ let started;
2980
3316
  try {
2981
- switch (cmd) {
2982
- case 'scan': process.exit(await cmdScan(args));
2983
- case 'ship': process.exit(await cmdShip(args));
2984
- case 'ci': process.exit(await cmdCi(args));
2985
- case 'fix': process.exit(await cmdFix(args));
2986
- case 'undo': process.exit(await cmdUndo(args));
2987
- case 'accept': process.exit(await cmdAccept(args));
2988
- case 'profile': process.exit(await cmdProfile(args));
2989
- case 'triage': process.exit(await cmdTriage(args));
2990
- case 'org-scan': process.exit(await cmdOrgScan(args));
2991
- case 'rules': process.exit(await cmdRules(args));
2992
- case 'rule': process.exit(await cmdRule(args));
2993
- case 'tickets': process.exit(await cmdTickets(args));
2994
- case 'secure': process.exit(await cmdSecure(args));
2995
- case 'packs': process.exit(await cmdPacks(args));
2996
- case 'validator-cache': process.exit(await cmdValidatorCache(args));
2997
- case 'verify': process.exit(await cmdVerify(args));
2998
- case 'reset': process.exit(await cmdReset(args));
2999
- case 'export': process.exit(await cmdExport(args));
3000
- case 'legal-hold': process.exit(await cmdLegalHold(args));
3001
- case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
3002
- case 'calibration-report': process.exit(await cmdCalibrationReport(args));
3003
- case 'hunt': process.exit(await cmdHunt(args));
3004
- case 'compliance': process.exit(await cmdCompliance(args));
3005
- case 'attest': process.exit(await cmdAttest(args));
3006
- case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
3007
- case 'policy-explain': process.exit(await cmdPolicyExplain(args));
3008
- case 'policy-sign': process.exit(await cmdPolicySign(args));
3009
- case 'rule-synth': process.exit(await cmdRuleSynth(args));
3010
- case 'digest': process.exit(await cmdDigest(args));
3011
- case 'setup': process.exit(await cmdSetup(args));
3012
- case 'cache-report': {
3013
- // Prompt-cache economics for the current session: parse the Claude Code
3014
- // transcript usage and report cache-hit %, $ saved, and avoidable leaks.
3015
- // Advisory/read-only always exits 0.
3016
- const { analyzeTranscript, formatCacheReport } = await import('../src/posture/cache-economics.js');
3017
- const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
3018
- const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
3019
- if (args.flags.json) writeStdout(JSON.stringify(result, null, 2) + '\n');
3020
- else console.log(formatCacheReport(result));
3021
- process.exit(0);
3317
+ started = await createExploreServer({ graph: loaded.graph, port, sessionToken, keepOpen });
3318
+ } catch (e) {
3319
+ process.stderr.write(`agentic-security explore: failed to start server: ${e && e.message ? e.message : e}\n`);
3320
+ return 1;
3321
+ }
3322
+ const { server, port: actualPort } = started;
3323
+
3324
+ // THE ONLY place the session token is ever displayed — never written to
3325
+ // a file, never logged by the server itself after this one print. The
3326
+ // token travels as a URL FRAGMENT (`#token=...`), never a query string:
3327
+ // a fragment is never sent to the server in any HTTP request (so it can
3328
+ // never be captured in an access log), yet the page's own JS can read it
3329
+ // once via location.hash and attach it as a header on every subsequent
3330
+ // /api/v1/* fetch() call (frontend/src/lib/api-client.js).
3331
+ process.stdout.write(`agentic-security explore: serving ${targetAbs}\n`);
3332
+ process.stdout.write(` URL: http://127.0.0.1:${actualPort}/#token=${sessionToken}\n`);
3333
+ process.stdout.write(' Open this URL in a browser — the page authenticates itself automatically.\n');
3334
+ if (keepOpen) {
3335
+ process.stdout.write(' --keep-open set: no idle-timeout auto-stop. Ctrl-C to stop.\n');
3336
+ } else {
3337
+ process.stdout.write(' Server auto-stops after a period of inactivity, or Ctrl-C to stop now.\n');
3338
+ }
3339
+
3340
+ return new Promise((resolve) => {
3341
+ server.on('close', () => resolve(0));
3342
+ });
3343
+ }
3344
+
3345
+ // `agentic-security dataflow export [path] --format <fmt> --output <file>
3346
+ // [--view <name>] [--size standard|2x] [--width <n>] [--height <n>]
3347
+ // [--no-redact] [--filter <path>]` — Milestone 4, sub-project 5 (CLI +
3348
+ // slash commands). Wires the six already-shipped M4 export/report
3349
+ // functions (scanner/scripts/export-image.mjs's exportPng/exportPdf/
3350
+ // exportSvg, scanner/src/lineage/export-json.js's exportGraphJSON,
3351
+ // scanner/src/lineage/export-csv.js's exportFlowsCSV, and
3352
+ // scanner/scripts/generate-html-report.mjs's generateHtmlReport) into
3353
+ // one consistent CLI surface.
3354
+ //
3355
+ // Argument shape mirrors cmdExplore's own: scan root from args._[2]
3356
+ // (args._[0]='dataflow', args._[1]='export', so the path — if given —
3357
+ // is the THIRD positional), defaulting to cwd. Uses the identical
3358
+ // loadSignedGraph contract and error-message pass-through as cmdExplore
3359
+ // (scoping doc's own binding decision: never proceed past a graph-load
3360
+ // failure).
3361
+ //
3362
+ // Exit codes: 0 success. 1 graph-load failure (loadSignedGraph's own
3363
+ // four reasons). 2 export-stage failure — bad/missing flags, an
3364
+ // unsupported format+view combination (svg + non-architecture view,
3365
+ // rejected BEFORE any Chrome invocation — Chrome's own dump-failure
3366
+ // reason for this case is confusing, not a good user-facing error), or
3367
+ // a caught throw/{ok:false} from the underlying export function.
3368
+ const DATAFLOW_EXPORT_FORMATS = new Set(['png', 'pdf', 'svg', 'json', 'csv', 'html', 'dpia', 'ropa', 'briefing', 'recipients', 'coverage']);
3369
+ const DATAFLOW_EXPORT_VIEWS = new Set(['architecture', 'privacy', 'trace', 'inventory']);
3370
+ const DATAFLOW_EXPORT_SIZES = { standard: { width: 1680, height: 945 }, '2x': { width: 3360, height: 1890 } };
3371
+
3372
+ async function cmdDataflowExport(args) {
3373
+ const target = args._[2] || '.';
3374
+ const targetAbs = path.resolve(target);
3375
+
3376
+ const format = args.flags.format;
3377
+ if (!format || !DATAFLOW_EXPORT_FORMATS.has(format)) {
3378
+ process.stderr.write(`agentic-security dataflow export: --format must be one of ${[...DATAFLOW_EXPORT_FORMATS].join('|')} (got ${JSON.stringify(format)}).\n`);
3379
+ return 2;
3380
+ }
3381
+ const outputPath = args.flags.output;
3382
+ if (!outputPath || typeof outputPath !== 'string') {
3383
+ process.stderr.write('agentic-security dataflow export: --output <file> is required.\n');
3384
+ return 2;
3385
+ }
3386
+ const viewExplicit = args.flags.view !== undefined;
3387
+ const view = args.flags.view || 'architecture';
3388
+ if (!DATAFLOW_EXPORT_VIEWS.has(view)) {
3389
+ process.stderr.write(`agentic-security dataflow export: --view must be one of ${[...DATAFLOW_EXPORT_VIEWS].join('|')} (got ${JSON.stringify(view)}).\n`);
3390
+ return 2;
3391
+ }
3392
+ if (format === 'svg' && view !== 'architecture') {
3393
+ process.stderr.write('agentic-security dataflow export: --format svg only supports --view architecture — only the Architecture View renders a real <svg> element.\n');
3394
+ return 2;
3395
+ }
3396
+ // json/csv/html are not view-scoped (exportGraphJSON/exportFlowsCSV have
3397
+ // no view concept; generateHtmlReport embeds the full interactive report,
3398
+ // not one captured view) — found by the final whole-branch review: an
3399
+ // explicit --view silently did nothing for these three formats, with no
3400
+ // warning, while the docs presented --view as a universal option.
3401
+ // dpia/ropa (M4 deliverable #10) join this same non-view-scoped set —
3402
+ // emitGraphDpiaArtifact/emitGraphRopaArtifact have no --view concept
3403
+ // either, mirroring json/csv's own precedent exactly. briefing (FR-501)
3404
+ // joins the same set — emitDecisionStory has no --view concept either.
3405
+ // recipients (FR-506) joins the same set — a Markdown table over
3406
+ // graph.recipientProfiles[] has no --view concept either.
3407
+ // coverage (M5, language coverage-tier disclosure) joins the same set —
3408
+ // a Markdown table over graph.coverage.languages[] has no --view concept
3409
+ // either.
3410
+ if (viewExplicit && (format === 'json' || format === 'csv' || format === 'html' || format === 'dpia' || format === 'ropa' || format === 'briefing' || format === 'recipients' || format === 'coverage')) {
3411
+ process.stderr.write(`agentic-security dataflow export: --view has no effect on --format ${format} — ${format} exports are not view-scoped.\n`);
3412
+ }
3413
+
3414
+ // --audience (FR-501, briefing only): parsed and enum-validated
3415
+ // unconditionally, matching --view's own precedent above — an invalid
3416
+ // value is a clear exit-2 error regardless of --format, not silently
3417
+ // accepted and then ignored. Defaults to export-briefing.js's own
3418
+ // documented default ('technical') when omitted. AUDIENCE_MODES is
3419
+ // imported (never hand-copied) so this validation can never drift from
3420
+ // emitDecisionStory's own enum — the same single-source-of-truth
3421
+ // discipline every other enum in this file follows.
3422
+ const { AUDIENCE_MODES } = await import('../src/lineage/export-briefing.js');
3423
+ const audienceExplicit = args.flags.audience !== undefined;
3424
+ const audienceMode = args.flags.audience || 'technical';
3425
+ if (!AUDIENCE_MODES.includes(audienceMode)) {
3426
+ process.stderr.write(`agentic-security dataflow export: --audience must be one of ${AUDIENCE_MODES.join('|')} (got ${JSON.stringify(audienceMode)}).\n`);
3427
+ return 2;
3428
+ }
3429
+ // Final whole-branch review finding (NITPICK, fixed): --view/--no-redact/
3430
+ // --filter each warn when given for a format that ignores them; --audience
3431
+ // silently no-op'd on every non-briefing format with no such warning.
3432
+ if (audienceExplicit && format !== 'briefing') {
3433
+ process.stderr.write(`agentic-security dataflow export: --audience has no effect on --format ${format} — only --format briefing is audience-aware.\n`);
3434
+ }
3435
+
3436
+ // A bare flag (no following value, e.g. "--width --height 500") is
3437
+ // parsed as boolean `true` by parseArgs — found by the final
3438
+ // whole-branch review: `Number(true) === 1` passed the old
3439
+ // Number.isSafeInteger guard unchanged, silently producing a 1-pixel
3440
+ // image at exit 0. Require a plain, unsigned, non-hex/non-exponential
3441
+ // digit string (rejects "true", "0x10", "1e3", leading zeros) AND cap
3442
+ // the value — an absurd-but-safe-integer width/height (e.g.
3443
+ // Number.MAX_SAFE_INTEGER) silently made Chrome fall back to its own
3444
+ // default size instead of erroring; a sane ceiling turns that into a
3445
+ // clean argument error instead of a silently-wrong image.
3446
+ const MAX_DATAFLOW_EXPORT_DIMENSION = 20000;
3447
+ function _isPlainPositiveIntString(s) {
3448
+ return typeof s === 'string' && /^[1-9]\d*$/.test(s);
3449
+ }
3450
+
3451
+ const sizeFlag = args.flags.size;
3452
+ const hasWidthHeight = args.flags.width !== undefined || args.flags.height !== undefined;
3453
+ if (sizeFlag !== undefined && hasWidthHeight) {
3454
+ process.stderr.write('agentic-security dataflow export: --size and --width/--height are mutually exclusive — pick one.\n');
3455
+ return 2;
3456
+ }
3457
+ let width, height;
3458
+ if (sizeFlag !== undefined) {
3459
+ if (!Object.prototype.hasOwnProperty.call(DATAFLOW_EXPORT_SIZES, sizeFlag)) {
3460
+ process.stderr.write(`agentic-security dataflow export: --size must be one of ${Object.keys(DATAFLOW_EXPORT_SIZES).join('|')} (got ${JSON.stringify(sizeFlag)}).\n`);
3461
+ return 2;
3462
+ }
3463
+ ({ width, height } = DATAFLOW_EXPORT_SIZES[sizeFlag]);
3464
+ } else if (hasWidthHeight) {
3465
+ if (!_isPlainPositiveIntString(args.flags.width) || !_isPlainPositiveIntString(args.flags.height)) {
3466
+ process.stderr.write('agentic-security dataflow export: --width/--height must both be positive integers.\n');
3467
+ return 2;
3468
+ }
3469
+ width = Number(args.flags.width);
3470
+ height = Number(args.flags.height);
3471
+ if (width > MAX_DATAFLOW_EXPORT_DIMENSION || height > MAX_DATAFLOW_EXPORT_DIMENSION) {
3472
+ process.stderr.write(`agentic-security dataflow export: --width/--height must both be <= ${MAX_DATAFLOW_EXPORT_DIMENSION}.\n`);
3473
+ return 2;
3474
+ }
3475
+ } else {
3476
+ ({ width, height } = DATAFLOW_EXPORT_SIZES.standard);
3477
+ }
3478
+
3479
+ const redact = args.flags['no-redact'] ? false : true;
3480
+ if (!redact && format === 'csv') {
3481
+ process.stderr.write('agentic-security dataflow export: --no-redact has no effect on --format csv — CSV export does not support redaction yet.\n');
3482
+ }
3483
+ // dpia/ropa (M4 deliverable #10): emitGraphDpiaArtifact/emitGraphRopaArtifact
3484
+ // never call exportGraphJSON's own redaction path either — same
3485
+ // precedent as csv above, same guard. briefing (FR-501) joins the same
3486
+ // set — emitDecisionStory never calls exportGraphJSON's redaction path
3487
+ // either. recipients (FR-506) joins the same set too — a judgment call,
3488
+ // disclosed: as of fix-round-1 (B1), `redact-graph.js`'s `_redactGraph`
3489
+ // DOES cover `graph.recipientProfiles[].technicalEndpoint`/`.legalEntity`/
3490
+ // `.retentionCommitment`/`.transferMechanism` — but `--no-redact` is
3491
+ // still a no-op for `recipients` specifically because
3492
+ // `_renderDataflowRecipientsMarkdown` below never calls
3493
+ // `exportGraphJSON`'s redaction path at all (it reads `graph` directly,
3494
+ // the same way dpia/ropa/briefing's own renderers do) — a fact about
3495
+ // THIS renderer's own code path, unrelated to whether `_redactGraph`
3496
+ // itself has an opinion about the field.
3497
+ // coverage (M5, language coverage-tier disclosure) joins the same set —
3498
+ // graph.coverage.languages[] carries only curated static tier data and
3499
+ // plain per-repo file counts, never a destination literal or evidence
3500
+ // snippet redact-graph.js has any opinion about.
3501
+ if (!redact && (format === 'dpia' || format === 'ropa' || format === 'briefing' || format === 'recipients' || format === 'coverage')) {
3502
+ process.stderr.write(`agentic-security dataflow export: --no-redact has no effect on --format ${format} — ${format} export does not support redaction yet.\n`);
3503
+ }
3504
+
3505
+ let filter;
3506
+ if (args.flags.filter !== undefined) {
3507
+ // A bare "--filter" (no value) or "--filter=" (empty string) must be
3508
+ // rejected up front, not passed to path.resolve() — found by the
3509
+ // final whole-branch review: path.resolve(true) throws a raw
3510
+ // TypeError that escapes uncaught to exit 4 (the same defect class
3511
+ // Task 1's own review found and fixed at the write stage, surviving
3512
+ // here on a different flag); an empty string was falsy and silently
3513
+ // dropped the whole filter instead of erroring.
3514
+ if (typeof args.flags.filter !== 'string' || !args.flags.filter) {
3515
+ process.stderr.write('agentic-security dataflow export: --filter requires a file path.\n');
3516
+ return 2;
3517
+ }
3518
+ const filterPath = path.resolve(args.flags.filter);
3519
+ try {
3520
+ filter = JSON.parse(fs.readFileSync(filterPath, 'utf8'));
3521
+ } catch (e) {
3522
+ process.stderr.write(`agentic-security dataflow export: could not read/parse --filter file "${args.flags.filter}": ${e.message}\n`);
3523
+ return 2;
3524
+ }
3525
+ // Milestone 5, large-graph pagination: this shape check now lives in
3526
+ // export-json.js's validateFilterShape (extracted verbatim from what
3527
+ // was previously this file's own inline copy) so the server's new
3528
+ // POST /api/v1/query endpoint and the dataflow_get_graph MCP tool
3529
+ // share the identical protection rather than a third, drifting copy.
3530
+ const { validateFilterShape } = await import('../src/lineage/export-json.js');
3531
+ const filterCheck = validateFilterShape(filter);
3532
+ if (!filterCheck.valid) {
3533
+ process.stderr.write(`agentic-security dataflow export: --filter file "${args.flags.filter}" ${filterCheck.error}.\n`);
3534
+ return 2;
3535
+ }
3536
+ // exportFlowsCSV(graph) takes no opts at all — found by the final
3537
+ // whole-branch review: --filter silently did nothing for --format
3538
+ // csv (an operator scoping a CSV export to a safe subset silently
3539
+ // got everything instead), with no warning, while both this file's
3540
+ // own USAGE text and commands/dataflow.md presented --filter as a
3541
+ // universal option.
3542
+ // coverage (M5, language coverage-tier disclosure) joins the same set —
3543
+ // a per-language table has no node/edge-id-scoped meaning to narrow by.
3544
+ if (format === 'csv' || format === 'coverage') {
3545
+ process.stderr.write(`agentic-security dataflow export: --filter has no effect on --format ${format} — ${format} export does not support scoping yet.\n`);
3546
+ }
3547
+ }
3548
+
3549
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
3550
+ const loaded = loadSignedGraph(targetAbs);
3551
+ if (!loaded.ok) {
3552
+ process.stderr.write(`agentic-security dataflow export: ${loaded.message}\n`);
3553
+ return 1;
3554
+ }
3555
+ const graph = loaded.graph;
3556
+ // Task-3 review finding (non-blocking, fixed): emitGraphDpiaArtifact's
3557
+ // own opts.generatedAt fallback is `new Date()` — omitting it here made
3558
+ // dpia/ropa the only two formats whose content reflects EXPORT-time
3559
+ // wall clock rather than the graph's own already-fixed generatedAt (the
3560
+ // same field json/html embed unconditionally), so exporting the
3561
+ // identical persisted graph twice on two different days produced two
3562
+ // different documents.
3563
+ const opts = { view, width, height, redact, filter, generatedAt: graph.generatedAt, audienceMode };
3564
+
3565
+ let data;
3566
+ try {
3567
+ if (format === 'png' || format === 'pdf' || format === 'svg') {
3568
+ const { exportPng, exportPdf, exportSvg } = await import('../scripts/export-image.mjs');
3569
+ const fn = { png: exportPng, pdf: exportPdf, svg: exportSvg }[format];
3570
+ const result = await fn(graph, opts);
3571
+ if (!result.ok) {
3572
+ process.stderr.write(`agentic-security dataflow export: ${result.reason}\n`);
3573
+ return 2;
3022
3574
  }
3023
- case 'cache-statusline': {
3024
- // F6 one-line cost HUD for a Claude Code statusLine command. Also writes
3025
- // .agentic-security/cache-telemetry.json for other pollers. Always exits 0.
3026
- const { analyzeTranscript, renderCacheStatusLine } = await import('../src/posture/cache-economics.js');
3027
- const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
3028
- const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
3029
- if (result.ok) {
3030
- try {
3031
- const dir = stateDir(projectDir);
3032
- fs.mkdirSync(dir, { recursive: true });
3033
- fs.writeFileSync(path.join(dir, 'cache-telemetry.json'),
3034
- JSON.stringify({ updatedAt: new Date().toISOString(), metrics: result.metrics, leaks: result.leaks }, null, 2));
3035
- } catch { /* best-effort */ }
3036
- console.log(renderCacheStatusLine(result.metrics));
3037
- } else {
3038
- console.log('agentic-security: no session cost yet');
3039
- }
3040
- process.exit(0);
3575
+ data = result.data;
3576
+ } else if (format === 'json') {
3577
+ const { exportGraphJSON } = await import('../src/lineage/export-json.js');
3578
+ data = JSON.stringify(exportGraphJSON(graph, opts), null, 2);
3579
+ } else if (format === 'csv') {
3580
+ const { exportFlowsCSV } = await import('../src/lineage/export-csv.js');
3581
+ data = exportFlowsCSV(graph);
3582
+ } else if (format === 'html') {
3583
+ const { generateHtmlReport } = await import('../scripts/generate-html-report.mjs');
3584
+ data = generateHtmlReport(graph, opts);
3585
+ } else if (format === 'dpia') {
3586
+ const { emitGraphDpiaArtifact } = await import('../src/lineage/export-privacy.js');
3587
+ data = emitGraphDpiaArtifact(graph, opts);
3588
+ } else if (format === 'ropa') {
3589
+ const { emitGraphRopaArtifact } = await import('../src/lineage/export-privacy.js');
3590
+ data = emitGraphRopaArtifact(graph, opts);
3591
+ } else if (format === 'briefing') {
3592
+ const { emitDecisionStory } = await import('../src/lineage/export-briefing.js');
3593
+ data = emitDecisionStory(graph, opts).markdown;
3594
+ } else if (format === 'recipients') {
3595
+ data = _renderDataflowRecipientsMarkdown(graph, opts);
3596
+ } else if (format === 'coverage') {
3597
+ data = _renderDataflowCoverageMarkdown(graph, opts);
3598
+ }
3599
+ } catch (e) {
3600
+ // dpia/ropa reach frontend/src/views/privacy-view.js via a relative
3601
+ // import that lives OUTSIDE the published scanner/ package directory
3602
+ // (`../../../frontend/...`, a repo-root sibling of scanner/) — real
3603
+ // for the `agentic-security`/`as` commands (dist/agentic-security.mjs
3604
+ // is ncc-bundled, so that content is already inlined and this path
3605
+ // never triggers there), but a genuine ERR_MODULE_NOT_FOUND when
3606
+ // someone runs THIS raw, unbundled file directly out of an installed
3607
+ // package's node_modules, reproduced live via a real `npm pack` +
3608
+ // install into a fresh consumer project. Give that one narrow case an
3609
+ // actionable message instead of a bare "Cannot find package
3610
+ // '@clear-capabilities/frontend'" that names a package the user has
3611
+ // never heard of. Scoped re-review finding (fixed): checking only
3612
+ // e.code === 'ERR_MODULE_NOT_FOUND' is too broad — it would also
3613
+ // catch and mislabel an UNRELATED module-resolution failure (a real
3614
+ // future bug, a genuinely broken install) with this same "you ran
3615
+ // the raw bin file" message, masking it. Requiring the message to
3616
+ // actually name the frontend import keeps this narrow to the one
3617
+ // real case it was written for.
3618
+ if ((format === 'dpia' || format === 'ropa') && e && e.code === 'ERR_MODULE_NOT_FOUND' && /privacy-view\.js/.test(e.message)) {
3619
+ process.stderr.write(`agentic-security dataflow export: export failed: --format ${format} could not load its frontend module (${e.message}). This usually means you ran the raw bin/agentic-security.js file directly out of an installed package instead of using the published \`agentic-security\`/\`as\` command (which is fully self-contained). Use the published command, or run from a full source checkout with frontend/ present alongside scanner/.\n`);
3620
+ return 2;
3621
+ }
3622
+ process.stderr.write(`agentic-security dataflow export: export failed: ${e && e.message ? e.message : e}\n`);
3623
+ return 2;
3624
+ }
3625
+
3626
+ const outAbs = path.resolve(outputPath);
3627
+ try {
3628
+ await fsp.mkdir(path.dirname(outAbs), { recursive: true });
3629
+ await fsp.writeFile(outAbs, data);
3630
+ } catch (e) {
3631
+ // Found by Task 1's own review, reproduced live (--output pointing at
3632
+ // an existing directory: EISDIR): left unguarded, this escaped to
3633
+ // main()'s generic top-level catch, printing a raw stack trace and
3634
+ // exiting 4 instead of the documented clean 0/1/2 contract.
3635
+ process.stderr.write(`agentic-security dataflow export: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
3636
+ return 2;
3637
+ }
3638
+ process.stdout.write(`agentic-security dataflow export: wrote ${format} to ${outAbs}\n`);
3639
+ return 0;
3640
+ }
3641
+
3642
+ // Local Markdown-escaping helpers for `--format recipients` — byte-identical
3643
+ // to export-briefing.js's/export-privacy.js's/`_dfDiffMd*`'s own
3644
+ // _mdInline/_mdCell/_mdCode, reimplemented locally per this codebase's
3645
+ // established per-module-owns-its-own-escaping-helpers convention (see
3646
+ // export-briefing.js's own header comment for why these are never imported
3647
+ // across modules). Applied to every graph-derived or operator-recipient-
3648
+ // config-derived string (provider/legalEntity/dpaStatus/etc.) interpolated
3649
+ // into the Markdown report below.
3650
+ function _dfRecipientsMdInline(value) {
3651
+ return String(value).replace(/\r\n|\r|\n/g, ' ');
3652
+ }
3653
+ function _dfRecipientsMdCell(value) {
3654
+ return _dfRecipientsMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
3655
+ }
3656
+ function _dfRecipientsMdCode(value) {
3657
+ const s = _dfRecipientsMdInline(value);
3658
+ const runs = s.match(/`+/g);
3659
+ const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
3660
+ if (maxRun === 0) return `\`${s}\``;
3661
+ const fence = '`'.repeat(maxRun + 1);
3662
+ return `${fence} ${s} ${fence}`;
3663
+ }
3664
+
3665
+ // `--filter`'s scoping rule for `--format recipients` (a real design
3666
+ // decision the task brief left open, disclosed here): a RecipientProfile
3667
+ // has no node/edge of its own — it is a §10.10 extension record keyed by
3668
+ // `recipientKey`, associated with the graph only via `contributingGraphIds`
3669
+ // (the sink NODE ids that resolved to it, per `graph-builder.js`'s own
3670
+ // `opts.buildRecipientProfile` hook). So this format narrows
3671
+ // `graph.recipientProfiles` by whether ANY of a profile's
3672
+ // `contributingGraphIds` survive `filter.nodeIds` — mirroring the same
3673
+ // "narrow by referential soundness against the filtered node set" spirit
3674
+ // `export-json.js`'s own `_filterGraph` uses for flows, applied to this
3675
+ // format's own, different entity shape. `filter.edgeIds` has no effect on
3676
+ // this format (a recipient profile carries no edge reference at all) — a
3677
+ // filter file supplying only `edgeIds` therefore narrows this format to
3678
+ // nothing, an honest (not silently-wrong) consequence of the format having
3679
+ // no edge concept, not a bug.
3680
+ function _filterRecipientProfiles(recipientProfiles, filter) {
3681
+ if (!filter) return recipientProfiles;
3682
+ const nodeIds = new Set(filter.nodeIds ?? []);
3683
+ return recipientProfiles.filter((p) => (p.contributingGraphIds ?? []).some((id) => nodeIds.has(id)));
3684
+ }
3685
+
3686
+ // The table columns named by the task brief — a deliberate SUBSET of
3687
+ // `RecipientProfile`'s full `RECIPIENT_FACT_FIELDS` (14 fields; see
3688
+ // recipient-profile.js), not all of them: technicalEndpoint/servicePurpose/
3689
+ // subprocessorChain/dataResidencyCommitment/observedRegion/
3690
+ // transferMechanism/transferImpactReviewStatus/retentionCommitment are
3691
+ // real fields but not part of the brief's own named column list, so they
3692
+ // are omitted from the table (a future format revision could add them).
3693
+ // `processingCountries` is rendered under the "Jurisdiction(s)" header per
3694
+ // the brief's own wording. `confidence` is record-level metadata (no
3695
+ // `fieldEvidence` entry of its own — see recipient-profile.js's own header
3696
+ // comment on why), so it is shown in the table but excluded from the
3697
+ // per-row evidence-disclosure footer below.
3698
+ const _DATAFLOW_RECIPIENTS_FACT_COLUMNS = [
3699
+ ['provider', 'Provider'],
3700
+ ['serviceType', 'Service Type'],
3701
+ ['legalEntity', 'Legal Entity'],
3702
+ ['processorRole', 'Processor Role'],
3703
+ ['processingCountries', 'Jurisdiction(s)'],
3704
+ ['dpaStatus', 'DPA Status'],
3705
+ ];
3706
+
3707
+ function _dataflowRecipientsCellValue(profile, field) {
3708
+ const value = profile[field];
3709
+ if (field === 'processingCountries') {
3710
+ return Array.isArray(value) && value.length > 0 ? value.join(', ') : '—';
3711
+ }
3712
+ return value === null || value === undefined || value === '' ? '—' : String(value);
3713
+ }
3714
+
3715
+ // `agentic-security dataflow export --format recipients` (Milestone 4,
3716
+ // FR-506, Task 3) — one Markdown table row per `graph.recipientProfiles[]`
3717
+ // entry, plus a footer disclosing which fields are `code_inferred` vs
3718
+ // `declared` vs absent for each row (never fabricated — a field with no
3719
+ // `fieldEvidence` entry is disclosed as `absent`, not silently omitted).
3720
+ function _renderDataflowRecipientsMarkdown(graph, opts = {}) {
3721
+ const profiles = _filterRecipientProfiles(graph.recipientProfiles ?? [], opts.filter);
3722
+ const lines = [];
3723
+ lines.push('# Third-Party and Cross-Border Recipient Intelligence');
3724
+ lines.push('');
3725
+ lines.push(`**Graph:** ${_dfRecipientsMdCode(graph.graphId ?? '(no graphId)')}`);
3726
+ lines.push(`**Generated:** ${_dfRecipientsMdInline(opts.generatedAt ?? graph.generatedAt ?? '')}`);
3727
+ if (opts.filter) lines.push('**Scope:** filtered to a subset of the graph (`--filter`).');
3728
+ lines.push('');
3729
+
3730
+ if (profiles.length === 0) {
3731
+ lines.push(opts.filter
3732
+ ? 'No recipients survive the given `--filter` scope.'
3733
+ : 'No recipients resolved for this graph — no sink site matched a known technical-provider catalog entry or an operator-declared `recipient-profiles.json` entry.');
3734
+ return `${lines.join('\n')}\n`;
3735
+ }
3736
+
3737
+ lines.push(`| ${_DATAFLOW_RECIPIENTS_FACT_COLUMNS.map(([, header]) => header).join(' | ')} | Confidence |`);
3738
+ lines.push(`|${_DATAFLOW_RECIPIENTS_FACT_COLUMNS.map(() => '---').join('|')}|---|`);
3739
+ for (const p of profiles) {
3740
+ const cells = _DATAFLOW_RECIPIENTS_FACT_COLUMNS.map(([field]) => _dfRecipientsMdCell(_dataflowRecipientsCellValue(p, field)));
3741
+ cells.push(_dfRecipientsMdCell(p.confidence ?? '—'));
3742
+ lines.push(`| ${cells.join(' | ')} |`);
3743
+ }
3744
+
3745
+ lines.push('');
3746
+ lines.push('## Field evidence');
3747
+ lines.push('');
3748
+ lines.push('For each recipient above, whether each shown fact is code-inferred (from a real call site matching this codebase\'s technical-provider catalog), declared (from operator-supplied `recipient-profiles.json`), or absent (no source at all — never fabricated):');
3749
+ lines.push('');
3750
+ for (const p of profiles) {
3751
+ lines.push(`### ${_dfRecipientsMdInline(p.provider ?? p.recipientKey ?? '(unknown recipient)')}`);
3752
+ for (const [field, header] of _DATAFLOW_RECIPIENTS_FACT_COLUMNS) {
3753
+ const ev = p.fieldEvidence ? p.fieldEvidence[field] : undefined;
3754
+ const status = ev ? ev.factType : 'absent';
3755
+ const sourceNote = ev && ev.source ? ` (source: ${_dfRecipientsMdInline(ev.source)})` : '';
3756
+ lines.push(`- ${_dfRecipientsMdInline(header)}: ${_dfRecipientsMdInline(status)}${sourceNote}`);
3757
+ }
3758
+ lines.push('');
3759
+ }
3760
+
3761
+ return `${lines.join('\n')}\n`;
3762
+ }
3763
+
3764
+ // Local Markdown-escaping helpers for `--format coverage` — byte-identical
3765
+ // to _dfRecipientsMdInline/_dfRecipientsMdCell/_dfRecipientsMdCode above,
3766
+ // reimplemented locally per this codebase's established
3767
+ // per-module-owns-its-own-escaping-helpers convention.
3768
+ function _dfCoverageMdInline(value) {
3769
+ return String(value).replace(/\r\n|\r|\n/g, ' ');
3770
+ }
3771
+ function _dfCoverageMdCell(value) {
3772
+ return _dfCoverageMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
3773
+ }
3774
+
3775
+ // Milestone 5, language coverage-tier disclosure. Renders graph.coverage.
3776
+ // languages[] (Task 1's own additive fields: tier/irTaintRecallPct/
3777
+ // measuredAt/source, alongside the pre-existing filesExpected/filesAnalyzed)
3778
+ // as a Markdown table, with an explicit disclosure paragraph distinguishing
3779
+ // the two different KINDS of fact in this report: real, per-repo file
3780
+ // counts (filesAnalyzed/filesExpected, computed fresh on every scan) versus
3781
+ // a curated, product-level recall estimate (irTaintRecallPct/measuredAt/
3782
+ // source, unchanged since docs/METRICS.md was last measured) — never
3783
+ // presented as one number, since conflating them would imply this scan
3784
+ // itself measured its own recall, which it did not.
3785
+ function _renderDataflowCoverageMarkdown(graph, opts = {}) {
3786
+ const lines = [];
3787
+ lines.push('# Language Coverage');
3788
+ lines.push('');
3789
+ lines.push(`**Graph:** \`${_dfCoverageMdInline(graph.graphId ?? '(no graphId)')}\``);
3790
+ lines.push(`**Generated:** ${_dfCoverageMdInline(opts.generatedAt ?? graph.generatedAt ?? '')}`);
3791
+ lines.push('');
3792
+ lines.push('`Files Analyzed`/`Files Expected` are real counts from THIS scan. `Tier` and `Recall (docs/METRICS.md)` are a curated, product-level estimate — unchanged since the date shown, not measured on this repository. The two are never the same kind of fact.');
3793
+ lines.push('');
3794
+ const languages = Array.isArray(graph.coverage?.languages) ? graph.coverage.languages : [];
3795
+ if (languages.length === 0) {
3796
+ lines.push('_No language coverage data available for this scan._');
3797
+ } else {
3798
+ lines.push('| Language | Files Analyzed | Files Expected | Tier | Recall (docs/METRICS.md) |');
3799
+ lines.push('|---|---|---|---|---|');
3800
+ for (const l of languages) {
3801
+ const recall = typeof l.irTaintRecallPct === 'number'
3802
+ ? `${l.irTaintRecallPct}% (as of ${_dfCoverageMdCell(l.measuredAt ?? '?')})`
3803
+ : '—';
3804
+ lines.push(`| ${_dfCoverageMdCell(l.language)} | ${_dfCoverageMdCell(l.filesAnalyzed)} | ${_dfCoverageMdCell(l.filesExpected)} | ${_dfCoverageMdCell(l.tier)} | ${recall} |`);
3805
+ }
3806
+ }
3807
+ lines.push('');
3808
+ return lines.join('\n');
3809
+ }
3810
+
3811
+ // `agentic-security dataflow diff [path] [--against <commit>]
3812
+ // [--drift-policy <path>] --output <file> [--format json|markdown]
3813
+ // [--fail-on-drift]` — Milestone 4, FR-503 sub-project 8b, Task 3. Reads
3814
+ // two persisted GraphSnapshot records (src/lineage/graph-snapshot.js),
3815
+ // computes a GraphDiff (src/lineage/graph-diff.js's computeGraphDiff, which
3816
+ // THROWS on an incomparable pair rather than returning an error object —
3817
+ // caught below and converted to the same clean-message-then-return-2
3818
+ // discipline cmdDataflowExport's own try/catch already established),
3819
+ // optionally evaluates operator drift policies against it
3820
+ // (src/lineage/drift-policy.js), and writes a JSON or Markdown report.
3821
+ //
3822
+ // Snapshot resolution — a real design decision the task brief itself left
3823
+ // open, disclosed here rather than in a comment only cmdDataflowExport's
3824
+ // own precedent would lead a reader to expect: graph-snapshot.js's own
3825
+ // git-HEAD resolver (`_gitHead`) is private and unexported, so this
3826
+ // function cannot independently ask "what is the current commit" the way
3827
+ // the scan-time persistence call site can. Instead, the CLI's own "AFTER"
3828
+ // snapshot is always `loadSnapshots(scanRoot)[0]` — the newest persisted
3829
+ // snapshot by file mtime — which is consistent with how a scan always
3830
+ // persists a snapshot for its own commit before `dataflow diff` would ever
3831
+ // run against that scanRoot (see test/cli/lineage-snapshot-persist.test.js
3832
+ // for the real, already-shipped precedent this assumption rests on).
3833
+ // `loadSnapshots(scanRoot)` returning `[]` is this function's "no snapshot
3834
+ // to compare against" exit-2 case. Without an explicit `--against`, the
3835
+ // "BEFORE" snapshot defaults to `mostRecentPriorSnapshot(scanRoot,
3836
+ // afterSnapshot.commit)` — also exit-2 if that is `null` (only one
3837
+ // snapshot exists, nothing to diff against yet). An explicit
3838
+ // `--against <commit>` resolves via `loadSnapshot(scanRoot, commit)` —
3839
+ // exit-2 with a clear message if that commit has no persisted snapshot.
3840
+ //
3841
+ // --format: the task brief's own invocation-signature line brackets
3842
+ // `[--format json|markdown]` as optional, but its own exit-code contract
3843
+ // explicitly lists "missing/invalid --format" as one of the enumerated
3844
+ // exit-2 cases — a real conflict between the two, resolved here (disclosed,
3845
+ // not silently picked) in favor of the more precisely-worded exit-code
3846
+ // contract: --format is REQUIRED, exactly like --output and exactly like
3847
+ // cmdDataflowExport's own --format above, never defaulted.
3848
+ //
3849
+ // --format json emits the raw GraphDiff record plus a `violations` array
3850
+ // (from evaluateDriftPolicies when --drift-policy was supplied). The task
3851
+ // brief leaves it as this implementation's own call whether to omit
3852
+ // `violations` or emit an empty array when no --drift-policy was given —
3853
+ // this always emits `violations: []` so the JSON shape is uniform across
3854
+ // both cases (a caller/test can always read `.violations` without an
3855
+ // `in`/`?.` check), disclosed here per the brief's own request.
3856
+ //
3857
+ // Exit codes, matching cmdDataflowExport's own documented contract shape:
3858
+ // 0 success (no violations, or violations found but --fail-on-drift not
3859
+ // passed); 2 usage/argument error (missing --output, missing/invalid
3860
+ // --format, no snapshot to compare against, an incomparable pair, a
3861
+ // malformed --drift-policy file); 1 if --drift-policy violations were
3862
+ // found AND --fail-on-drift was passed — the report is still written to
3863
+ // --output in that case, exactly like a passing run, so a CI caller gets
3864
+ // both the gate signal AND the artifact in one invocation.
3865
+ const DATAFLOW_DIFF_FORMATS = new Set(['json', 'markdown']);
3866
+
3867
+ /** Resolves a flow id against a real DataFlowGraph v1 document for
3868
+ * human-readable Markdown rendering — a GraphDiff's own added/removed/
3869
+ * changed entries carry only {id, causeClassification, ...} (see
3870
+ * graph-diff.js's own header), never dataElement/sink names directly.
3871
+ * Mirrors drift-policy.js's own private _resolveFlowContext in spirit
3872
+ * (same id-chasing logic), reimplemented locally here since that helper
3873
+ * is not exported and this CLI's own rendering needs are narrower (labels
3874
+ * only, no dataClasses/sinkCategory matching). Returns raw, UNESCAPED
3875
+ * strings — callers interpolating into Markdown MUST pass every returned
3876
+ * value through _mdInline/_mdCell/_mdCode below, never this function's
3877
+ * output directly. */
3878
+ function _dataflowDiffFlowLabel(flowId, graph) {
3879
+ const flow = (graph?.flows ?? []).find((f) => f.id === flowId);
3880
+ if (!flow) return { dataElementNames: [], sinkLabel: null };
3881
+ const dataElementNames = (flow.dataElementIds ?? [])
3882
+ .map((id) => (graph.dataElements ?? []).find((d) => d.id === id)?.name)
3883
+ .filter((n) => typeof n === 'string' && n.length > 0);
3884
+ const sinkNode = (graph.nodes ?? []).find((n) => n.id === flow.sink) ?? null;
3885
+ const sinkLabel = sinkNode?.label ?? sinkNode?.subtype ?? sinkNode?.kind ?? flow.sink ?? null;
3886
+ return { dataElementNames, sinkLabel };
3887
+ }
3888
+
3889
+ // Local Markdown-escaping helpers — byte-identical to export-briefing.js's/
3890
+ // export-privacy.js's own _mdInline/_mdCell/_mdCode, reimplemented locally
3891
+ // per this codebase's established per-module-owns-its-own-escaping-helpers
3892
+ // convention (see export-briefing.js's own header comment for why these
3893
+ // are never imported across modules). Applied to every graph-derived or
3894
+ // operator-drift-policy-derived string (flow ids, data element names, sink
3895
+ // labels, rule `reason` text) interpolated into the Markdown report below.
3896
+ function _dfDiffMdInline(value) {
3897
+ return String(value).replace(/\r\n|\r|\n/g, ' ');
3898
+ }
3899
+ function _dfDiffMdCell(value) {
3900
+ return _dfDiffMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
3901
+ }
3902
+ function _dfDiffMdCode(value) {
3903
+ const s = _dfDiffMdInline(value);
3904
+ const runs = s.match(/`+/g);
3905
+ const maxRun = runs ? Math.max(...runs.map((r) => r.length)) : 0;
3906
+ if (maxRun === 0) return `\`${s}\``;
3907
+ const fence = '`'.repeat(maxRun + 1);
3908
+ return `${fence} ${s} ${fence}`;
3909
+ }
3910
+
3911
+ function _renderDataflowDiffMarkdown(diff, violations, beforeSnapshot, afterSnapshot, driftPolicyProvided) {
3912
+ const graphAfter = afterSnapshot.graph ?? {};
3913
+ const graphBefore = beforeSnapshot.graph ?? {};
3914
+ const lines = [];
3915
+
3916
+ lines.push('# Data Flow Explorer — Graph Diff');
3917
+ lines.push('');
3918
+ lines.push(`**Before:** commit ${_dfDiffMdCode(beforeSnapshot.commit)} — captured ${_dfDiffMdInline(beforeSnapshot.capturedAt)}`);
3919
+ lines.push(`**After:** commit ${_dfDiffMdCode(afterSnapshot.commit)} — captured ${_dfDiffMdInline(afterSnapshot.capturedAt)}`);
3920
+ lines.push(`**Generated:** ${_dfDiffMdInline(diff.generatedAt)}`);
3921
+ lines.push('');
3922
+
3923
+ // Drift-policy violations get their own top section — flagged
3924
+ // prominently, never buried under the added/removed/changed detail, per
3925
+ // the task brief's own explicit instruction.
3926
+ lines.push('## Drift Policy Violations');
3927
+ lines.push('');
3928
+ if (!driftPolicyProvided) {
3929
+ lines.push('_No `--drift-policy` supplied — policy evaluation was skipped._');
3930
+ } else if (violations.length === 0) {
3931
+ lines.push('No drift-policy violations detected.');
3932
+ } else {
3933
+ lines.push('| Trigger | Flow | Data Elements | Sink | Reason |');
3934
+ lines.push('|---|---|---|---|---|');
3935
+ for (const v of violations) {
3936
+ const cells = [
3937
+ v.trigger,
3938
+ v.flowId,
3939
+ (v.dataElementNames ?? []).join(', ') || '(none)',
3940
+ v.sinkCategory ?? v.sinkNodeId ?? 'unknown',
3941
+ v.reason,
3942
+ ];
3943
+ lines.push(`| ${cells.map(_dfDiffMdCell).join(' | ')} |`);
3944
+ }
3945
+ }
3946
+ lines.push('');
3947
+
3948
+ lines.push('## Summary');
3949
+ lines.push('');
3950
+ lines.push(`- Nodes: +${diff.added.nodes.length} / -${diff.removed.nodes.length}`);
3951
+ lines.push(`- Edges: +${diff.added.edges.length} / -${diff.removed.edges.length}`);
3952
+ lines.push(`- Data elements: +${diff.added.dataElements.length} / -${diff.removed.dataElements.length}`);
3953
+ lines.push(`- Flows: +${diff.added.flows.length} / -${diff.removed.flows.length} / ~${diff.changed.flows.length} changed`);
3954
+ lines.push('');
3955
+
3956
+ const _entityList = (entries) => entries.map((e) => `- ${_dfDiffMdCode(e.id)} (${_dfDiffMdInline(e.causeClassification)})`);
3957
+
3958
+ lines.push('## Added');
3959
+ lines.push('');
3960
+ lines.push('### Flows');
3961
+ if (diff.added.flows.length === 0) {
3962
+ lines.push('_None._');
3963
+ } else {
3964
+ for (const e of diff.added.flows) {
3965
+ const { dataElementNames, sinkLabel } = _dataflowDiffFlowLabel(e.id, graphAfter);
3966
+ const de = dataElementNames.length ? dataElementNames.map(_dfDiffMdCode).join(', ') : 'unclassified data';
3967
+ // Reidentification (fix round 1, Important 1 — see graph-diff.js's
3968
+ // own judgment call #4): this is the SAME real-world flow as
3969
+ // e.reidentifiedFrom, not a genuinely new code-level flow — only
3970
+ // engine confidence/shape (evidenceGrade/transformationIds) changed.
3971
+ // Must be surfaced here, never hidden, or a Markdown-only reader has
3972
+ // no way to see the distinction the JSON report carries.
3973
+ const flag = e.causeClassification === 'reidentified'
3974
+ ? ` **(reidentified — same flow as ${_dfDiffMdCode(e.reidentifiedFrom)}, not a new code-level flow; only engine confidence/shape changed)**`
3975
+ : '';
3976
+ lines.push(`- ${_dfDiffMdCode(e.id)} — ${de} → ${_dfDiffMdCode(sinkLabel ?? 'unknown sink')} (first seen ${_dfDiffMdInline(e.firstSeen?.commit ?? '')})${flag}`);
3977
+ }
3978
+ }
3979
+ lines.push('');
3980
+ for (const [heading, key] of [['Nodes', 'nodes'], ['Edges', 'edges'], ['Data Elements', 'dataElements']]) {
3981
+ lines.push(`### ${heading}`);
3982
+ lines.push(diff.added[key].length === 0 ? '_None._' : _entityList(diff.added[key]).join('\n'));
3983
+ lines.push('');
3984
+ }
3985
+
3986
+ lines.push('## Removed');
3987
+ lines.push('');
3988
+ lines.push('### Flows');
3989
+ if (diff.removed.flows.length === 0) {
3990
+ lines.push('_None._');
3991
+ } else {
3992
+ for (const e of diff.removed.flows) {
3993
+ const { dataElementNames, sinkLabel } = _dataflowDiffFlowLabel(e.id, graphBefore);
3994
+ const de = dataElementNames.length ? dataElementNames.map(_dfDiffMdCode).join(', ') : 'unclassified data';
3995
+ // Minor 1: inline the real reasons — the old text pointed at
3996
+ // coverageRegressionReasons without ever rendering it, so a
3997
+ // Markdown-only reader had no way to see the field at all.
3998
+ // Reidentification flag mirrors the ## Added / ### Flows loop above
3999
+ // (fix round 1, Important 1).
4000
+ let flag = '';
4001
+ if (e.causeClassification === 'possible_coverage_regression') {
4002
+ flag = ` **(possible coverage regression: ${_dfDiffMdInline((e.coverageRegressionReasons ?? []).join('; '))})**`;
4003
+ } else if (e.causeClassification === 'reidentified') {
4004
+ flag = ` **(reidentified — same flow as ${_dfDiffMdCode(e.reidentifiedTo)}, not a real removal; only engine confidence/shape changed)**`;
3041
4005
  }
3042
- case 'mcp': {
3043
- const { runStdio } = await import('../src/mcp/stdio.js');
3044
- const root = args.flags.root || process.env.AGENTIC_SECURITY_MCP_ROOT || process.cwd();
3045
- runStdio({ sessionRoot: path.resolve(root) });
3046
- return;
4006
+ lines.push(`- ${_dfDiffMdCode(e.id)} — ${de} → ${_dfDiffMdCode(sinkLabel ?? 'unknown sink')} (last seen ${_dfDiffMdInline(e.lastSeen?.commit ?? '')})${flag}`);
4007
+ }
4008
+ }
4009
+ lines.push('');
4010
+ for (const [heading, key] of [['Nodes', 'nodes'], ['Edges', 'edges'], ['Data Elements', 'dataElements']]) {
4011
+ lines.push(`### ${heading}`);
4012
+ lines.push(diff.removed[key].length === 0 ? '_None._' : _entityList(diff.removed[key]).join('\n'));
4013
+ lines.push('');
4014
+ }
4015
+
4016
+ lines.push('## Changed');
4017
+ lines.push('');
4018
+ lines.push('### Flows');
4019
+ if (diff.changed.flows.length === 0) {
4020
+ lines.push('_None._');
4021
+ } else {
4022
+ for (const e of diff.changed.flows) {
4023
+ const changeText = e.changes.map((c) => `${_dfDiffMdInline(c.field)}: ${_dfDiffMdCode(JSON.stringify(c.before))} -> ${_dfDiffMdCode(JSON.stringify(c.after))}`).join('; ');
4024
+ lines.push(`- ${_dfDiffMdCode(e.id)} — ${changeText}`);
4025
+ }
4026
+ }
4027
+ lines.push('');
4028
+
4029
+ return lines.join('\n');
4030
+ }
4031
+
4032
+ // Shared between `dataflow diff` and `dataflow watch` — parse-then-shape-
4033
+ // check a --drift-policy file BEFORE ever calling loadDriftPolicies.
4034
+ // loadDriftPolicies (src/lineage/drift-policy.js) never throws — a
4035
+ // malformed file degrades to {policies: []} with only a console.error
4036
+ // warning, matching loadPrivacySinkPolicy's own precedent. That is the
4037
+ // right default for a LOADER with no caller-facing error channel, but both
4038
+ // CLI commands' own exit-code contracts explicitly promise exit 2 for "a
4039
+ // malformed --drift-policy file" — so the malformed-JSON case, AND the
4040
+ // wrong-top-level-shape case (syntactically valid JSON, wrong shape, which
4041
+ // previously loaded zero rules with zero warning — sub-project 8b's own fix
4042
+ // round, Important 2), are both detected independently, here, before
4043
+ // delegating to the real loader for the actual (identically-parsed) policy
4044
+ // content. `cmdLabel` names the subcommand ("diff"/"watch") in the error
4045
+ // text. Returns `{ok:true}` or `{ok:false, message}` — never throws.
4046
+ function _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, cmdLabel) {
4047
+ if (!fs.existsSync(driftPolicyAbs)) {
4048
+ return { ok: false, message: `agentic-security dataflow ${cmdLabel}: --drift-policy file "${driftPolicyFlag}" does not exist.\n` };
4049
+ }
4050
+ let parsedDriftPolicy;
4051
+ try {
4052
+ parsedDriftPolicy = JSON.parse(fs.readFileSync(driftPolicyAbs, 'utf8'));
4053
+ } catch (e) {
4054
+ return { ok: false, message: `agentic-security dataflow ${cmdLabel}: could not parse --drift-policy file "${driftPolicyFlag}": ${e.message}\n` };
4055
+ }
4056
+ if (!parsedDriftPolicy || typeof parsedDriftPolicy !== 'object' || Array.isArray(parsedDriftPolicy) || !Array.isArray(parsedDriftPolicy.policies)) {
4057
+ return { ok: false, message: `agentic-security dataflow ${cmdLabel}: --drift-policy file "${driftPolicyFlag}" must be a JSON object of the form {"policies":[...]} (got a different shape).\n` };
4058
+ }
4059
+ return { ok: true };
4060
+ }
4061
+
4062
+ async function cmdDataflowDiff(args) {
4063
+ const target = args._[2] || '.';
4064
+ const targetAbs = path.resolve(target);
4065
+
4066
+ const outputPath = args.flags.output;
4067
+ if (!outputPath || typeof outputPath !== 'string') {
4068
+ process.stderr.write('agentic-security dataflow diff: --output <file> is required.\n');
4069
+ return 2;
4070
+ }
4071
+
4072
+ const format = args.flags.format;
4073
+ if (!format || !DATAFLOW_DIFF_FORMATS.has(format)) {
4074
+ process.stderr.write(`agentic-security dataflow diff: --format must be one of ${[...DATAFLOW_DIFF_FORMATS].join('|')} (got ${JSON.stringify(format)}).\n`);
4075
+ return 2;
4076
+ }
4077
+
4078
+ const againstFlag = args.flags.against;
4079
+ if (againstFlag !== undefined && (typeof againstFlag !== 'string' || !againstFlag)) {
4080
+ process.stderr.write('agentic-security dataflow diff: --against requires a commit value.\n');
4081
+ return 2;
4082
+ }
4083
+
4084
+ const driftPolicyFlag = args.flags['drift-policy'];
4085
+ if (driftPolicyFlag !== undefined && (typeof driftPolicyFlag !== 'string' || !driftPolicyFlag)) {
4086
+ process.stderr.write('agentic-security dataflow diff: --drift-policy requires a file path.\n');
4087
+ return 2;
4088
+ }
4089
+
4090
+ const failOnDrift = !!args.flags['fail-on-drift'];
4091
+
4092
+ // Snapshot resolution — see this function's own header comment above for
4093
+ // the full disclosed reasoning (no unexported git-HEAD resolver
4094
+ // available to this CLI, so "AFTER" is always the newest persisted
4095
+ // snapshot for scanRoot).
4096
+ const snapshots = loadSnapshots(targetAbs);
4097
+ if (snapshots.length === 0) {
4098
+ process.stderr.write(`agentic-security dataflow diff: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
4099
+ return 2;
4100
+ }
4101
+ const afterSnapshot = snapshots[0];
4102
+
4103
+ let beforeSnapshot;
4104
+ if (againstFlag !== undefined) {
4105
+ beforeSnapshot = loadSnapshot(targetAbs, againstFlag);
4106
+ if (!beforeSnapshot) {
4107
+ process.stderr.write(`agentic-security dataflow diff: no persisted GraphSnapshot found for commit "${againstFlag}" — pass a commit that was actually scanned.\n`);
4108
+ return 2;
4109
+ }
4110
+ } else {
4111
+ beforeSnapshot = mostRecentPriorSnapshot(targetAbs, afterSnapshot.commit);
4112
+ if (!beforeSnapshot) {
4113
+ process.stderr.write(`agentic-security dataflow diff: only one persisted GraphSnapshot exists (commit "${afterSnapshot.commit}") — nothing to compare against yet. Scan again after a code change, or pass --against <commit>.\n`);
4114
+ return 2;
4115
+ }
4116
+ }
4117
+
4118
+ // Fix round 1, Minor 2: --against resolving to the SAME commit as the
4119
+ // current ("AFTER") snapshot is a plausible CI mistake that would
4120
+ // otherwise silently resolve an all-empty diff and pass --fail-on-drift.
4121
+ // The default (--against omitted) path can't hit this on its own —
4122
+ // mostRecentPriorSnapshot explicitly excludes afterSnapshot.commit — but
4123
+ // checking here, after both branches converge, is simpler and correct
4124
+ // either way.
4125
+ if (beforeSnapshot.commit === afterSnapshot.commit) {
4126
+ process.stderr.write(`agentic-security dataflow diff: refusing a self-diff — --against resolved to the same commit as the current snapshot ("${afterSnapshot.commit}"). Pass a different --against, or scan again after a code change.\n`);
4127
+ return 2;
4128
+ }
4129
+
4130
+ const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
4131
+ let diff;
4132
+ try {
4133
+ diff = computeGraphDiff(beforeSnapshot, afterSnapshot);
4134
+ } catch (e) {
4135
+ // computeGraphDiff THROWS (never returns an error object) on an
4136
+ // incomparable pair — converted to the same clean-message-then-
4137
+ // return-2 discipline cmdDataflowExport's own try/catch establishes.
4138
+ process.stderr.write(`agentic-security dataflow diff: ${e && e.message ? e.message : e}\n`);
4139
+ return 2;
4140
+ }
4141
+
4142
+ let violations = [];
4143
+ const driftPolicyProvided = driftPolicyFlag !== undefined;
4144
+ if (driftPolicyProvided) {
4145
+ const driftPolicyAbs = path.resolve(driftPolicyFlag);
4146
+ const check = _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, 'diff');
4147
+ if (!check.ok) {
4148
+ process.stderr.write(check.message);
4149
+ return 2;
4150
+ }
4151
+
4152
+ const { loadDriftPolicies, evaluateDriftPolicies } = await import('../src/lineage/drift-policy.js');
4153
+ const policies = loadDriftPolicies(driftPolicyAbs);
4154
+ violations = evaluateDriftPolicies(diff, policies, afterSnapshot.graph).violations;
4155
+ }
4156
+
4157
+ let data;
4158
+ if (format === 'json') {
4159
+ data = JSON.stringify({ ...diff, violations }, null, 2);
4160
+ } else {
4161
+ data = _renderDataflowDiffMarkdown(diff, violations, beforeSnapshot, afterSnapshot, driftPolicyProvided);
4162
+ }
4163
+
4164
+ const outAbs = path.resolve(outputPath);
4165
+ try {
4166
+ await fsp.mkdir(path.dirname(outAbs), { recursive: true });
4167
+ await fsp.writeFile(outAbs, data);
4168
+ } catch (e) {
4169
+ process.stderr.write(`agentic-security dataflow diff: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
4170
+ return 2;
4171
+ }
4172
+
4173
+ process.stdout.write(`agentic-security dataflow diff: wrote ${format} to ${outAbs} (${violations.length} drift-policy violation(s))\n`);
4174
+
4175
+ if (failOnDrift && violations.length > 0) return 1;
4176
+ return 0;
4177
+ }
4178
+
4179
+ // agentic-security dataflow scenario apply [path] --operations <file>
4180
+ // --output <file> [--format json|markdown] [--privacy-sink-policy <file>]
4181
+ // [--environment <name>] — M5 deliverable #3a (FR-502). Loads the
4182
+ // already-scanned, already-signed graph via loadSignedGraph (same
4183
+ // loader/error-message contract as cmdDataflowExport/cmdDataflowDiff),
4184
+ // applies the Scenario in --operations via applyScenario, diffs the
4185
+ // result against the base graph via diffScenarioGraph, writes the
4186
+ // report to --output. Exit codes: 0 success, 1 graph-load failure
4187
+ // (loadSignedGraph's own 4 messages), 2 argument/operations-file
4188
+ // problem.
4189
+ async function cmdDataflowScenarioApply(args) {
4190
+ const target = args._[3] || '.'; // args._ = ['dataflow', 'scenario', 'apply', <path>?]
4191
+ const targetAbs = path.resolve(target);
4192
+
4193
+ const operationsFlag = args.flags.operations;
4194
+ if (!operationsFlag || typeof operationsFlag !== 'string') {
4195
+ process.stderr.write('agentic-security dataflow scenario apply: --operations <file> is required.\n');
4196
+ return 2;
4197
+ }
4198
+ const outputPath = args.flags.output;
4199
+ if (!outputPath || typeof outputPath !== 'string') {
4200
+ process.stderr.write('agentic-security dataflow scenario apply: --output <file> is required.\n');
4201
+ return 2;
4202
+ }
4203
+ const format = args.flags.format ?? 'json';
4204
+ if (format !== 'json' && format !== 'markdown') {
4205
+ process.stderr.write(`agentic-security dataflow scenario apply: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
4206
+ return 2;
4207
+ }
4208
+
4209
+ let opsInput;
4210
+ try {
4211
+ opsInput = JSON.parse(fs.readFileSync(path.resolve(operationsFlag), 'utf8'));
4212
+ } catch (e) {
4213
+ process.stderr.write(`agentic-security dataflow scenario apply: could not read/parse --operations file "${operationsFlag}": ${e.message}\n`);
4214
+ return 2;
4215
+ }
4216
+
4217
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
4218
+ const loaded = loadSignedGraph(targetAbs);
4219
+ if (!loaded.ok) {
4220
+ process.stderr.write(`agentic-security dataflow scenario apply: ${loaded.message}\n`);
4221
+ return 1;
4222
+ }
4223
+ const baseGraph = loaded.graph;
4224
+
4225
+ const { validateScenario } = await import('../src/lineage/scenario.js');
4226
+ const { computeGraphDigest } = await import('../src/lineage/export-json.js');
4227
+ const { scenarioId } = await import('../src/lineage/ids.js');
4228
+ const baseGraphDigest = computeGraphDigest(baseGraph);
4229
+ const cliAuthor = opsInput.author ?? 'cli';
4230
+ const cliCreatedAt = new Date().toISOString();
4231
+ const scenarioDraft = {
4232
+ id: scenarioId({ graphId: baseGraph.graphId, graphDigest: baseGraphDigest }, [cliAuthor, cliCreatedAt]),
4233
+ version: '1.0.0',
4234
+ baseGraphId: baseGraph.graphId, baseGraphDigest,
4235
+ operations: opsInput.operations ?? [],
4236
+ assumptions: opsInput.assumptions ?? [], author: cliAuthor,
4237
+ createdAt: cliCreatedAt, expiration: null,
4238
+ simulatedDelta: null, verificationRequirements: opsInput.verificationRequirements ?? [],
4239
+ };
4240
+ const { valid, errors } = validateScenario(scenarioDraft);
4241
+ if (!valid) {
4242
+ process.stderr.write(`agentic-security dataflow scenario apply: --operations file failed validation:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}\n`);
4243
+ return 2;
4244
+ }
4245
+
4246
+ let privacySinkPolicy;
4247
+ const policyFlag = args.flags['privacy-sink-policy'];
4248
+ if (policyFlag !== undefined) {
4249
+ try {
4250
+ privacySinkPolicy = JSON.parse(fs.readFileSync(path.resolve(policyFlag), 'utf8'));
4251
+ } catch (e) {
4252
+ process.stderr.write(`agentic-security dataflow scenario apply: could not read/parse --privacy-sink-policy file "${policyFlag}": ${e.message}\n`);
4253
+ return 2;
4254
+ }
4255
+ }
4256
+
4257
+ const { applyScenario } = await import('../src/lineage/scenario-engine.js');
4258
+ const { diffScenarioGraph } = await import('../src/lineage/scenario-diff.js');
4259
+ const opts = { privacySinkPolicy, environment: args.flags.environment };
4260
+ const { graph: scenarioGraph, appliedOperations, skippedOperations } = applyScenario(baseGraph, scenarioDraft, opts);
4261
+ const { changedEntities, removedEntityIds } = diffScenarioGraph(baseGraph, scenarioGraph);
4262
+
4263
+ const report = { scenarioId: scenarioDraft.id, appliedOperations, skippedOperations, changedEntities, removedEntityIds, generatedAt: new Date().toISOString() };
4264
+ let data;
4265
+ if (format === 'json') {
4266
+ data = JSON.stringify(report, null, 2);
4267
+ } else {
4268
+ const lines = [`# Scenario delta`, '', `Applied ${appliedOperations.length} operation(s), skipped ${skippedOperations.length}.`, ''];
4269
+ if (skippedOperations.length) {
4270
+ lines.push('## Skipped operations', '');
4271
+ for (const s of skippedOperations) lines.push(`- \`${s.operation.kind}\`: ${s.reason}`);
4272
+ lines.push('');
4273
+ }
4274
+ lines.push('## Changed entities', '');
4275
+ for (const c of changedEntities) {
4276
+ lines.push(`- **${c.kind} ${c.id}**${c.label ? ` — **${c.label}**` : ''}`);
4277
+ for (const f of c.changedFields) lines.push(` - \`${f.field}\`: ${JSON.stringify(f.before)} → ${JSON.stringify(f.after)}`);
4278
+ }
4279
+ if (removedEntityIds.length) {
4280
+ lines.push('', '## Removed entities', '');
4281
+ for (const id of removedEntityIds) lines.push(`- ${id}`);
4282
+ }
4283
+ data = lines.join('\n') + '\n';
4284
+ }
4285
+ try {
4286
+ await fsp.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
4287
+ await fsp.writeFile(path.resolve(outputPath), data);
4288
+ } catch (e) {
4289
+ process.stderr.write(`agentic-security dataflow scenario apply: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
4290
+ return 2;
4291
+ }
4292
+ return 0;
4293
+ }
4294
+
4295
+ // agentic-security dataflow impact assess [path] --target <canonical-id>
4296
+ // --output <file> [--format json|markdown] — M5 deliverable #4
4297
+ // (FR-507). Loads the already-scanned, already-signed graph via
4298
+ // loadSignedGraph (same loader/error-message contract as every other
4299
+ // dataflow subcommand), computes an ImpactAssessment via
4300
+ // computeImpactAssessment, writes it to --output. Exit codes: 0
4301
+ // success; 1 graph-load failure (loadSignedGraph's own 4 messages) OR
4302
+ // a structurally malformed graph reaching computeImpactAssessment
4303
+ // (loadSignedGraph performs no schema validation — a graph-CONTENT
4304
+ // problem, not a CLI argument problem); 2 a genuine CLI argument
4305
+ // problem (missing --target/--output, or a --target with no
4306
+ // recognized canonical-id prefix).
4307
+ async function cmdDataflowImpactAssess(args) {
4308
+ const target = args._[3] || '.'; // args._ = ['dataflow', 'impact', 'assess', <path>?]
4309
+ const targetAbs = path.resolve(target);
4310
+
4311
+ const targetIdFlag = args.flags.target;
4312
+ if (!targetIdFlag || typeof targetIdFlag !== 'string') {
4313
+ process.stderr.write('agentic-security dataflow impact assess: --target <canonical-id> is required.\n');
4314
+ return 2;
4315
+ }
4316
+ const outputPath = args.flags.output;
4317
+ if (!outputPath || typeof outputPath !== 'string') {
4318
+ process.stderr.write('agentic-security dataflow impact assess: --output <file> is required.\n');
4319
+ return 2;
4320
+ }
4321
+ const format = args.flags.format ?? 'json';
4322
+ if (format !== 'json' && format !== 'markdown') {
4323
+ process.stderr.write(`agentic-security dataflow impact assess: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
4324
+ return 2;
4325
+ }
4326
+
4327
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
4328
+ const loaded = loadSignedGraph(targetAbs);
4329
+ if (!loaded.ok) {
4330
+ process.stderr.write(`agentic-security dataflow impact assess: ${loaded.message}\n`);
4331
+ return 1;
4332
+ }
4333
+
4334
+ const { computeImpactAssessment } = await import('../src/lineage/impact-engine.js');
4335
+ let record;
4336
+ try {
4337
+ record = computeImpactAssessment(loaded.graph, targetIdFlag);
4338
+ } catch (e) {
4339
+ const message = e && e.message ? e.message : String(e);
4340
+ process.stderr.write(`agentic-security dataflow impact assess: ${message}\n`);
4341
+ // A malformed graph (e.g. a signed-but-structurally-broken document —
4342
+ // loadSignedGraph performs no schema validation, only signature +
4343
+ // JSON.parse) is a graph-CONTENT problem, not a CLI argument
4344
+ // problem — exit 1, matching loadSignedGraph's own failure exit
4345
+ // code above, not the exit-2 "bad --target/--output" case below.
4346
+ return message.startsWith('computeImpactAssessment: malformed graph') ? 1 : 2;
4347
+ }
4348
+
4349
+ let data;
4350
+ if (format === 'json') {
4351
+ data = JSON.stringify(record, null, 2);
4352
+ } else {
4353
+ const lines = [
4354
+ `# Impact assessment`, '',
4355
+ `id: \`${record.id}\``,
4356
+ `graphId: \`${record.graphId}\``,
4357
+ `graphDigest: \`${record.graphDigest}\``,
4358
+ `generatedAt: ${record.generatedAt}`, '',
4359
+ `Target: \`${record.targetId}\` (${record.targetKind})`,
4360
+ `Scope: ${record.scope} (traceKind: ${record.traceKind})`, '',
4361
+ `## Affected nodes (${record.affectedNodeIds.length})`, '',
4362
+ ...record.affectedNodeIds.map((id) => `- ${id}`), '',
4363
+ `## Affected edges (${record.affectedEdgeIds.length})`, '',
4364
+ ...record.affectedEdgeIds.map((id) => `- ${id}`), '',
4365
+ `## Affected data classes`, '',
4366
+ record.affectedDataClasses.length ? record.affectedDataClasses.map((c) => `- ${c}`).join('\n') : '_none_', '',
4367
+ `## Affected recipients`, '',
4368
+ record.affectedRecipientProfileIds.length ? record.affectedRecipientProfileIds.map((id) => `- ${id}`).join('\n') : '_none_', '',
4369
+ ];
4370
+ if (record.coverageLimitations.length) {
4371
+ lines.push('## Coverage limitations', '', ...record.coverageLimitations.map((s) => `- ${s}`), '');
4372
+ }
4373
+ data = lines.join('\n') + '\n';
4374
+ }
4375
+ try {
4376
+ await fsp.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
4377
+ await fsp.writeFile(path.resolve(outputPath), data);
4378
+ } catch (e) {
4379
+ process.stderr.write(`agentic-security dataflow impact assess: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
4380
+ return 2;
4381
+ }
4382
+ return 0;
4383
+ }
4384
+
4385
+ // ── dataflow observations import/list + dataflow twin — M5 deliverable #7
4386
+ // (Runtime-Corroborated Digital Twin, "7b" — runtime-observed only; FR-505,
4387
+ // AC-29). This is the AC-29 proof surface: the first place a human operator
4388
+ // can actually see and act on the whole deliverable.
4389
+ //
4390
+ // `observations import` is the one mutating verb in this trio, and it is
4391
+ // dry-run-by-default exactly like `governance propose-edit`/`remediation
4392
+ // open`: without --yes it computes and prints exactly what WOULD be
4393
+ // imported and writes nothing; with --yes it writes through
4394
+ // `persistObservationImport` (the store's own closed-world validation is
4395
+ // the authoritative gate — this command's own `validateRuntimeObservation`
4396
+ // call below is a deliberate PREVIEW, so an operator sees a rejection
4397
+ // before ever passing --yes) and appends a real `auditCall` entry, never
4398
+ // on a dry run and never on a rejected import.
4399
+ //
4400
+ // Exit-code contract, identical to `dataflow impact assess`'s plus an
4401
+ // explicit 4: 0 success (preview or real write); 1 a validation failure (a
4402
+ // rejected record, a malformed adapter input, a graph-load failure via
4403
+ // `loadSignedGraph`'s own four messages); 2 a usage/argument error or an
4404
+ // `isSafeStateDir` refusal; 4 an unexpected I/O error during the write
4405
+ // itself — nothing was written and no audit event was recorded.
4406
+ //
4407
+ // Refuse the WHOLE import, never a partial one (AC-29 clause 5): every
4408
+ // adapter parse error AND every per-record `validateRuntimeObservation`
4409
+ // failure is collected across the whole file before any exit decision is
4410
+ // made, so a payload-shaped record caught at the wire layer (an unknown
4411
+ // top-level key) and a payload-shaped record caught one layer up (an
4412
+ // unapproved attribute key) are both named in the SAME refusal — silently
4413
+ // dropping just the ones the adapter caught and importing the rest would
4414
+ // be exactly the partial-import failure this deliverable exists to
4415
+ // prevent.
4416
+ // M5 (final review): --source is a second unconstrained, ≤512-char,
4417
+ // verbatim-persisted, verbatim-echoed-by-`observations list` value channel
4418
+ // — smaller than B1's own attribute-value hole but the same shape. Mirrors
4419
+ // runtime-observation.js's own `_isIdentifierValue` character class
4420
+ // locally rather than importing it — that module's own constant caps at
4421
+ // 128 characters (the attribute-value cap), while `source` is validated up
4422
+ // to 512 characters (`validateRuntimeObservation`'s own `$.source` check),
4423
+ // so reusing the 128-char-capped version verbatim would reject a
4424
+ // legitimate long-but-real source name the schema itself still permits.
4425
+ // A real filename basename (this flag's own default) already satisfies
4426
+ // this grammar.
4427
+ const _SOURCE_IDENTIFIER_RE = /^[A-Za-z0-9._:-]{1,512}$/;
4428
+
4429
+ async function cmdDataflowObservationsImport(args) {
4430
+ const target = args._[3] || '.'; // args._ = ['dataflow', 'observations', 'import', <path>?]
4431
+ const targetAbs = path.resolve(target);
4432
+
4433
+ const { adapterFor } = await import('../src/lineage/observation-adapters.js');
4434
+ const adapterFlag = args.flags.adapter;
4435
+ if (!adapterFlag || typeof adapterFlag !== 'string') {
4436
+ process.stderr.write('agentic-security dataflow observations import: --adapter <name> is required (currently only "native-jsonl" is supported).\n');
4437
+ return 2;
4438
+ }
4439
+ const adapterImpl = adapterFor(adapterFlag);
4440
+ if (!adapterImpl) {
4441
+ process.stderr.write(`agentic-security dataflow observations import: unknown --adapter "${adapterFlag}" — only "native-jsonl" is supported.\n`);
4442
+ return 2;
4443
+ }
4444
+
4445
+ const inputFlag = args.flags.input;
4446
+ if (!inputFlag || typeof inputFlag !== 'string') {
4447
+ process.stderr.write('agentic-security dataflow observations import: --input <file> is required.\n');
4448
+ return 2;
4449
+ }
4450
+ const inputPath = path.resolve(inputFlag);
4451
+ if (!fs.existsSync(inputPath)) {
4452
+ process.stderr.write(`agentic-security dataflow observations import: --input file not found: "${inputFlag}".\n`);
4453
+ return 2;
4454
+ }
4455
+
4456
+ const _isoOk = (v) => typeof v === 'string' && v.length > 0 && Number.isFinite(Date.parse(v));
4457
+ const windowStart = args.flags['window-start'];
4458
+ const windowEnd = args.flags['window-end'];
4459
+ if (!_isoOk(windowStart) || !_isoOk(windowEnd)) {
4460
+ process.stderr.write('agentic-security dataflow observations import: --window-start and --window-end are both required and must be parseable ISO-8601 date-times.\n');
4461
+ return 2;
4462
+ }
4463
+ if (Date.parse(windowStart) > Date.parse(windowEnd)) {
4464
+ process.stderr.write('agentic-security dataflow observations import: --window-start must not be after --window-end.\n');
4465
+ return 2;
4466
+ }
4467
+
4468
+ // M1 (final review): --retain-until is recorded as retention.expiresAt
4469
+ // and NOTHING ELSE — it is not currently enforced in either direction.
4470
+ // An import past its declared expiresAt is not automatically deleted
4471
+ // (grep confirms zero consumers of retention.expiresAt outside this
4472
+ // store's own validator and `observations list`'s display column), and
4473
+ // `reset --yes` sweeps the WHOLE store regardless of any individual
4474
+ // import's own expiresAt. Real enforcement is separate, larger scope
4475
+ // (touching `reset`/`retention-policy.js`) — this comment (and
4476
+ // `commands/dataflow.md`'s own matching wording) exists so this flag's
4477
+ // NAME never implies protection the implementation does not provide.
4478
+ const retainUntilFlag = args.flags['retain-until'];
4479
+ let retainUntil = null;
4480
+ if (retainUntilFlag !== undefined) {
4481
+ if (!_isoOk(retainUntilFlag)) {
4482
+ process.stderr.write('agentic-security dataflow observations import: --retain-until must be a parseable ISO-8601 date-time. Note: this value is recorded as retention.expiresAt but is NOT currently enforced by anything — an import past its declared expiresAt is not auto-deleted, and `reset --yes` sweeps the whole store regardless.\n');
4483
+ return 2;
4484
+ }
4485
+ retainUntil = retainUntilFlag;
4486
+ }
4487
+
4488
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
4489
+ const loaded = loadSignedGraph(targetAbs);
4490
+ if (!loaded.ok) {
4491
+ process.stderr.write(`agentic-security dataflow observations import: ${loaded.message}\n`);
4492
+ return 1;
4493
+ }
4494
+
4495
+ const adapter = adapterFlag;
4496
+ const source = (typeof args.flags.source === 'string' && args.flags.source) || path.basename(inputPath);
4497
+ const environment = (typeof args.flags.environment === 'string' && args.flags.environment) || 'unspecified';
4498
+
4499
+ // M5 (final review): refuse a --source value that doesn't look like an
4500
+ // identifier/filename-shaped token, BEFORE reading the --input file's
4501
+ // own content — the same value-axis smuggling shape B1 closed on
4502
+ // attribute values, applied here since --source is persisted verbatim
4503
+ // and echoed by `observations list`.
4504
+ if (!_SOURCE_IDENTIFIER_RE.test(source)) {
4505
+ process.stderr.write(`agentic-security dataflow observations import: --source must look like an identifier/filename (letters, digits, '.', '_', ':', '-', 512 characters max) — a value containing whitespace, quotes, or punctuation is how a payload arrives disguised as metadata (got ${JSON.stringify(source)}).\n`);
4506
+ return 2;
4507
+ }
4508
+
4509
+ let text;
4510
+ try {
4511
+ text = fs.readFileSync(inputPath, 'utf8');
4512
+ } catch (e) {
4513
+ process.stderr.write(`agentic-security dataflow observations import: could not read --input "${inputFlag}": ${e && e.message ? e.message : e}\n`);
4514
+ return 2;
4515
+ }
4516
+
4517
+ const { RUNTIME_OBSERVATION_VERSION, validateRuntimeObservation } = await import('../src/lineage/runtime-observation.js');
4518
+ const importedAt = new Date().toISOString();
4519
+ const context = {
4520
+ adapter, source, environment, windowStart, windowEnd, importedAt,
4521
+ retention: { expiresAt: retainUntil },
4522
+ version: RUNTIME_OBSERVATION_VERSION,
4523
+ };
4524
+ const { drafts, errors: parseErrors } = adapterImpl.parse(text, context);
4525
+
4526
+ const { matchObservationToGraph } = await import('../src/lineage/observation-correlation.js');
4527
+ const { observationId, observationImportId } = await import('../src/lineage/ids.js');
4528
+
4529
+ // Never print an attribute VALUE anywhere in this function (AC-29
4530
+ // clause 5's own "no captured payload... exists in the observation
4531
+ // artifact" extends to this preview, which is not the artifact itself
4532
+ // but must not become a second leak channel for the same values).
4533
+ const records = [];
4534
+ const recordErrors = [];
4535
+ let matchedCount = 0;
4536
+ let unmatchedCount = 0;
4537
+ drafts.forEach((draft, i) => {
4538
+ const match = matchObservationToGraph(loaded.graph, draft);
4539
+ const fingerprint = Object.entries(draft.attributes ?? {})
4540
+ .sort()
4541
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
4542
+ .join('&');
4543
+ const id = observationId(
4544
+ { adapter, environment: draft.environment, windowStart: draft.windowStart, windowEnd: draft.windowEnd },
4545
+ [fingerprint],
4546
+ );
4547
+ const record = {
4548
+ id, version: draft.version, adapter: draft.adapter, source: draft.source,
4549
+ environment: draft.environment, windowStart: draft.windowStart, windowEnd: draft.windowEnd,
4550
+ matchedNodeIds: match.matchedNodeIds, matchedEdgeIds: match.matchedEdgeIds, matchedFlowIds: match.matchedFlowIds,
4551
+ attributes: draft.attributes, eventCountBand: draft.eventCountBand,
4552
+ firstObservedAt: draft.firstObservedAt, lastObservedAt: draft.lastObservedAt,
4553
+ matchMethod: match.matchMethod, matchConfidence: match.matchConfidence,
4554
+ retention: draft.retention, importedAt: draft.importedAt,
4555
+ };
4556
+ const { valid, errors } = validateRuntimeObservation(record);
4557
+ if (!valid) {
4558
+ recordErrors.push({ index: i + 1, errors });
4559
+ } else {
4560
+ records.push(record);
4561
+ if (match.matchMethod === 'unmatched') unmatchedCount++; else matchedCount++;
4562
+ }
4563
+ });
4564
+
4565
+ // Refuse the WHOLE import — deliberately, so a partial import can never
4566
+ // silently drop the offending record (AC-29 clause 5). Both error
4567
+ // sources (the adapter's own wire-shape rejections AND this command's
4568
+ // preview validateRuntimeObservation rejections) are combined into one
4569
+ // report before any exit decision is made.
4570
+ if (parseErrors.length > 0 || recordErrors.length > 0) {
4571
+ process.stderr.write('agentic-security dataflow observations import: refusing the WHOLE import — a partial import that silently drops an offending record would misrepresent what the operator believes the artifact holds (AC-29 clause 5). Nothing was written. Every offending record:\n');
4572
+ for (const e of parseErrors) {
4573
+ process.stderr.write(` line ${e.line}: ${e.message}\n`);
4574
+ }
4575
+ for (const re of recordErrors) {
4576
+ for (const e of re.errors) {
4577
+ process.stderr.write(` record ${re.index}: ${e.path}: ${e.message}\n`);
4578
+ }
4579
+ }
4580
+ return 1;
4581
+ }
4582
+
4583
+ const { OBSERVATION_IMPORT_VERSION } = await import('../src/lineage/observation-store.js');
4584
+ // B2 (final review): importedAt alone is millisecond-resolution, so two
4585
+ // concurrent imports sharing adapter/source/environment/window CAN land
4586
+ // in the same millisecond and mint the identical import id — the same
4587
+ // file name, one silently clobbering the other while both report
4588
+ // success. A fresh random discriminator makes that collision impossible
4589
+ // regardless of timing, mirroring this package's own established
4590
+ // discriminatorParts convention (pathId/scenarioId/etc., ids.js).
4591
+ const importId = observationImportId(
4592
+ { adapter, source, environment, windowStart, windowEnd, importedAt },
4593
+ [crypto.randomBytes(4).toString('hex')],
4594
+ );
4595
+ const importRecord = {
4596
+ id: importId, version: OBSERVATION_IMPORT_VERSION, adapter, source, environment,
4597
+ windowStart, windowEnd, importedAt, retention: { expiresAt: retainUntil },
4598
+ observations: records,
4599
+ };
4600
+
4601
+ const preview = {
4602
+ adapter, source, environment, windowStart, windowEnd,
4603
+ recordCount: records.length, matched: matchedCount, unmatched: unmatchedCount,
4604
+ retention: { expiresAt: retainUntil }, importId, written: false,
4605
+ };
4606
+
4607
+ const yes = !!args.flags.yes;
4608
+ if (!yes) {
4609
+ process.stdout.write(JSON.stringify(preview, null, 2) + '\n');
4610
+ return 0;
4611
+ }
4612
+
4613
+ // `isSafeStateDir(statePath(targetAbs, 'runtime-observations'))` —
4614
+ // string literal, third registry-guard call site (the other two are
4615
+ // `observation-store.js` and `index.js`) — before any mkdirSync/write.
4616
+ const dir = statePath(targetAbs, 'runtime-observations');
4617
+ const { isSafeStateDir } = await import('../src/posture/state-dir.js');
4618
+ if (!isSafeStateDir(dir)) {
4619
+ process.stderr.write(`agentic-security dataflow observations import: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
4620
+ return 2;
4621
+ }
4622
+
4623
+ const { persistObservationImport } = await import('../src/lineage/observation-store.js');
4624
+ let result;
4625
+ try {
4626
+ result = persistObservationImport(targetAbs, importRecord);
4627
+ } catch (e) {
4628
+ process.stderr.write(`agentic-security dataflow observations import: unexpected error writing the store: ${e && e.message ? e.message : e}\n`);
4629
+ return 4;
4630
+ }
4631
+ if (!result.ok) {
4632
+ process.stderr.write(`agentic-security dataflow observations import: ${result.reason}\n`);
4633
+ return result.reason && result.reason.startsWith('invalid ObservationImport record') ? 1 : 4;
4634
+ }
4635
+
4636
+ const { auditCall } = await import('../src/mcp/audit.js');
4637
+ auditCall({
4638
+ sessionRoot: targetAbs,
4639
+ tool: 'dataflow_observations_import',
4640
+ args: { adapter, source, environment, windowStart, windowEnd, observations: records.length, matched: matchedCount, importId },
4641
+ outcome: 'ok',
4642
+ });
4643
+
4644
+ preview.written = true;
4645
+ process.stdout.write(JSON.stringify(preview, null, 2) + '\n');
4646
+ return 0;
4647
+ }
4648
+
4649
+ // agentic-security dataflow observations list [path] [--json] — read-only,
4650
+ // never writes, exit 0 always (an empty store is not an error). Never
4651
+ // prints an attribute key or value anywhere (CLI/list-2) — every row is
4652
+ // built from ONLY the import-level metadata fields below; no per-
4653
+ // observation `.attributes` object is ever touched by this function.
4654
+ async function cmdDataflowObservationsList(args) {
4655
+ const target = args._[3] || '.'; // args._ = ['dataflow', 'observations', 'list', <path>?]
4656
+ const targetAbs = path.resolve(target);
4657
+
4658
+ const { loadObservationImports } = await import('../src/lineage/observation-store.js');
4659
+ const imports = loadObservationImports(targetAbs);
4660
+
4661
+ const rows = imports.map((imp) => ({
4662
+ importId: imp.id,
4663
+ adapter: imp.adapter,
4664
+ source: imp.source,
4665
+ environment: imp.environment,
4666
+ windowStart: imp.windowStart,
4667
+ windowEnd: imp.windowEnd,
4668
+ observations: Array.isArray(imp.observations) ? imp.observations.length : 0,
4669
+ importedAt: imp.importedAt,
4670
+ expiresAt: imp.retention?.expiresAt ?? 'no expiry declared',
4671
+ }));
4672
+
4673
+ if (args.flags.json) {
4674
+ process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
4675
+ return 0;
4676
+ }
4677
+
4678
+ if (rows.length === 0) {
4679
+ process.stdout.write('No runtime observation imports found.\n');
4680
+ return 0;
4681
+ }
4682
+
4683
+ for (const r of rows) {
4684
+ process.stdout.write(
4685
+ `${r.importId} adapter=${r.adapter} source=${r.source} environment=${r.environment} `
4686
+ + `window=${r.windowStart}..${r.windowEnd} observations=${r.observations} `
4687
+ + `importedAt=${r.importedAt} expiresAt=${r.expiresAt}\n`,
4688
+ );
4689
+ }
4690
+ return 0;
4691
+ }
4692
+
4693
+ // Local Markdown-escaping helpers for `dataflow twin --format markdown` —
4694
+ // byte-identical to _dfCoverageMdInline/_dfCoverageMdCell above,
4695
+ // reimplemented locally per this codebase's established
4696
+ // per-module-owns-its-own-escaping-helpers convention.
4697
+ function _dfTwinMdInline(value) {
4698
+ return String(value).replace(/\r\n|\r|\n/g, ' ');
4699
+ }
4700
+ function _dfTwinMdCell(value) {
4701
+ return _dfTwinMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
4702
+ }
4703
+
4704
+ const _TWIN_LAYER_DISPLAY = Object.freeze({
4705
+ runtime_observed: 'RUNTIME OBSERVED',
4706
+ not_observed_in_window: 'not_observed_in_window',
4707
+ not_evaluated: 'not_evaluated',
4708
+ });
4709
+
4710
+ // agentic-security dataflow twin [path] --output <file> [--format
4711
+ // json|markdown] [--environment <name>] [--window-start <iso>]
4712
+ // [--window-end <iso>] — the AC-29 Runtime Digital Twin proof surface.
4713
+ // Read-only: never writes into .agentic-security/ (CLI/twin-7). `null`
4714
+ // (never `[]`) observations means "no store was consulted" — the
4715
+ // not_evaluated signal correlateObservations relies on to keep the
4716
+ // three-valued layer honest (AC-29 clause 2).
4717
+ async function cmdDataflowTwin(args) {
4718
+ const target = args._[2] || '.'; // args._ = ['dataflow', 'twin', <path>?]
4719
+ const targetAbs = path.resolve(target);
4720
+
4721
+ const outputPath = args.flags.output;
4722
+ if (!outputPath || typeof outputPath !== 'string') {
4723
+ process.stderr.write('agentic-security dataflow twin: --output <file> is required.\n');
4724
+ return 2;
4725
+ }
4726
+ const format = args.flags.format ?? 'json';
4727
+ if (format !== 'json' && format !== 'markdown') {
4728
+ process.stderr.write(`agentic-security dataflow twin: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
4729
+ return 2;
4730
+ }
4731
+
4732
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
4733
+ const loaded = loadSignedGraph(targetAbs);
4734
+ if (!loaded.ok) {
4735
+ process.stderr.write(`agentic-security dataflow twin: ${loaded.message}\n`);
4736
+ return 1;
4737
+ }
4738
+
4739
+ const environment = typeof args.flags.environment === 'string' ? args.flags.environment : null;
4740
+ const windowStart = typeof args.flags['window-start'] === 'string' ? args.flags['window-start'] : null;
4741
+ const windowEnd = typeof args.flags['window-end'] === 'string' ? args.flags['window-end'] : null;
4742
+
4743
+ // Fourth registry-guard call site — string literal, matching
4744
+ // observation-store.js/index.js/cmdDataflowObservationsImport above.
4745
+ const dir = statePath(targetAbs, 'runtime-observations');
4746
+ const observations = fs.existsSync(dir)
4747
+ ? (await import('../src/lineage/observation-store.js')).loadObservations(targetAbs)
4748
+ : null;
4749
+
4750
+ const { correlateObservations } = await import('../src/lineage/observation-correlation.js');
4751
+ const result = correlateObservations(loaded.graph, observations, { environment, windowStart, windowEnd });
4752
+
4753
+ let data;
4754
+ if (format === 'json') {
4755
+ data = JSON.stringify(result, null, 2);
4756
+ } else {
4757
+ const { computeGraphDigest } = await import('../src/lineage/export-json.js');
4758
+ const nodesById = new Map(loaded.graph.nodes.map((n) => [n.id, n]));
4759
+ const flowsById = new Map(loaded.graph.flows.map((f) => [f.id, f]));
4760
+ const lines = [];
4761
+ lines.push('# Runtime Digital Twin', '');
4762
+ lines.push(`**Graph:** \`${_dfTwinMdInline(loaded.graph.graphId ?? '(no graphId)')}\``);
4763
+ lines.push(`**Graph digest:** \`${_dfTwinMdInline(computeGraphDigest(loaded.graph))}\``);
4764
+ lines.push(`**Environment filter:** ${_dfTwinMdInline(result.environment ?? '(none)')}`);
4765
+ lines.push(`**Window:** ${_dfTwinMdInline(result.windowStart ?? '(open)')} .. ${_dfTwinMdInline(result.windowEnd ?? '(open)')}`, '');
4766
+ lines.push('## Layers', '');
4767
+ lines.push('| Flow | Source | Sink | Layer |');
4768
+ lines.push('|---|---|---|---|');
4769
+ const flowIds = Object.keys(result.byFlow).sort();
4770
+ for (const fid of flowIds) {
4771
+ const flow = flowsById.get(fid);
4772
+ const srcLabel = flow ? (nodesById.get(flow.source)?.label ?? flow.source) : '(unknown)';
4773
+ const snkLabel = flow ? (nodesById.get(flow.sink)?.label ?? flow.sink) : '(unknown)';
4774
+ const entry = result.byFlow[fid];
4775
+ const layerDisplay = _TWIN_LAYER_DISPLAY[entry.layer] ?? entry.layer;
4776
+ lines.push(`| ${_dfTwinMdCell(fid)} | ${_dfTwinMdCell(srcLabel)} | ${_dfTwinMdCell(snkLabel)} | ${_dfTwinMdCell(layerDisplay)} |`);
4777
+ }
4778
+ lines.push('');
4779
+
4780
+ const observedFlowIdsSorted = flowIds.filter((fid) => result.byFlow[fid].layer === 'runtime_observed');
4781
+ if (observedFlowIdsSorted.length > 0) {
4782
+ lines.push('## Runtime-observed flow detail', '');
4783
+ for (const fid of observedFlowIdsSorted) {
4784
+ const entry = result.byFlow[fid];
4785
+ lines.push(`### ${_dfTwinMdInline(fid)}`, '');
4786
+ lines.push(`- Match method: ${_dfTwinMdInline(entry.matchMethod ?? '(none)')}`);
4787
+ lines.push(`- Match confidence: ${_dfTwinMdInline(entry.matchConfidence ?? '(none)')}`);
4788
+ lines.push(`- Environment: ${_dfTwinMdInline(entry.environment ?? '(none)')}`);
4789
+ lines.push(`- Window: ${_dfTwinMdInline(entry.windowStart ?? '(none)')} .. ${_dfTwinMdInline(entry.windowEnd ?? '(none)')}`);
4790
+ lines.push(`- First observed: ${_dfTwinMdInline(entry.firstObservedAt ?? '(none)')}`);
4791
+ lines.push(`- Last observed: ${_dfTwinMdInline(entry.lastObservedAt ?? '(none)')}`);
4792
+ lines.push(`- Event count band: ${_dfTwinMdInline(entry.eventCountBand ?? '(none)')}`);
4793
+ if (Array.isArray(entry.contributingEnvironments) && entry.contributingEnvironments.length > 1) {
4794
+ lines.push(`- Contributing environments: ${_dfTwinMdInline(entry.contributingEnvironments.join(', '))} — the fields above describe only the representative (strongest-confidence) environment (I2).`);
4795
+ }
4796
+ if (entry.siblingFlowCount) {
4797
+ lines.push(`- Sibling flow count: ${_dfTwinMdInline(entry.siblingFlowCount)} — this observation corroborates the destination NODE, never which sibling flow produced the traffic, which is why matchConfidence reads 'ambiguous' here.`);
4798
+ }
4799
+ lines.push('');
4800
+ }
4801
+ }
4802
+
4803
+ lines.push('## Limitations', '');
4804
+ for (const l of result.limitations ?? []) {
4805
+ lines.push(`- ${_dfTwinMdInline(l)}`);
4806
+ }
4807
+ lines.push("- `not_observed_in_window` means the flow was not observed in the selected environment/window — it does NOT mean the flow does not occur (PRD line 2098).");
4808
+ lines.push('- Runtime observation increases corroboration confidence but cannot prove field-level identity — observations correlate to node/edge/flow ids only, never a specific data element instance (FR-505).');
4809
+ lines.push('- Every statically possible path in this graph is listed above regardless of layer; nothing was filtered out (AC-29 clause 3).');
4810
+ lines.push('');
4811
+ data = lines.join('\n');
4812
+ }
4813
+
4814
+ try {
4815
+ await fsp.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
4816
+ await fsp.writeFile(path.resolve(outputPath), data);
4817
+ } catch (e) {
4818
+ process.stderr.write(`agentic-security dataflow twin: could not write --output "${outputPath}": ${e && e.message ? e.message : e}\n`);
4819
+ return 2;
4820
+ }
4821
+ return 0;
4822
+ }
4823
+
4824
+ // Mirrors posture/fix-history.js's own _writeAtomicAndSync (temp file in
4825
+ // the same directory, fsync, then rename over the target) — a crash
4826
+ // mid-write must never leave recipient-profiles.json as invalid JSON,
4827
+ // since loadRecipientConfig silently degrades a malformed file to "no
4828
+ // recipients" with only a console warning, not a hard failure.
4829
+ async function _writeConfigAtomic(fp, content) {
4830
+ const dir = path.dirname(fp);
4831
+ await fsp.mkdir(dir, { recursive: true });
4832
+ const tmp = path.join(dir, `.${path.basename(fp)}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
4833
+ try {
4834
+ const handle = await fsp.open(tmp, 'w');
4835
+ try {
4836
+ await handle.writeFile(content);
4837
+ if (typeof handle.sync === 'function') await handle.sync();
4838
+ } finally {
4839
+ await handle.close();
4840
+ }
4841
+ // Preserve the target's existing permissions (if any) — this file
4842
+ // may hold sensitive governance data (DPA/jurisdiction/transfer
4843
+ // facts) an operator deliberately restricted; a fresh temp file
4844
+ // would otherwise silently widen it to the process umask (real bug
4845
+ // found live by the final review: chmod 600 -> 0644 after a write).
4846
+ try {
4847
+ const { mode } = await fsp.stat(fp);
4848
+ await fsp.chmod(tmp, mode & 0o777);
4849
+ } catch { /* target doesn't exist yet — nothing to preserve */ }
4850
+ await fsp.rename(tmp, fp);
4851
+ } catch (e) {
4852
+ try { await fsp.unlink(tmp); } catch { /* never existed, or already gone — fine either way */ }
4853
+ throw e;
4854
+ }
4855
+ }
4856
+
4857
+ // agentic-security governance propose-edit [path] --patch <file>
4858
+ // [--output <file>] [--yes] [--base-digest <hex>] — M5 deliverable #5.
4859
+ // Proposes a validated, reviewable edit to recipient-profiles.json.
4860
+ // Without --yes: computes and previews the diff, writes nothing.
4861
+ // With --yes: re-validates, checks the version guard, backs up the
4862
+ // current file, writes the new content atomically, and appends a real
4863
+ // audit event via auditCall. Exit codes: 0 success (preview or real
4864
+ // write), 1 validation failure, 2 argument/version-guard problem.
4865
+ //
4866
+ // NOT a `dataflow` subcommand — this edits operator config
4867
+ // (recipient-profiles.json), never the scanned graph, so it is its own
4868
+ // top-level `governance` command (see `case 'governance':` in main()).
4869
+ async function cmdGovernancePropose(args) {
4870
+ const target = args._[2] || '.'; // args._ = ['governance', 'propose-edit', <path>?]
4871
+ const targetAbs = path.resolve(target);
4872
+
4873
+ const patchFlag = args.flags.patch;
4874
+ if (!patchFlag || typeof patchFlag !== 'string') {
4875
+ process.stderr.write('agentic-security governance propose-edit: --patch <file> is required.\n');
4876
+ return 2;
4877
+ }
4878
+ let patch;
4879
+ try {
4880
+ patch = JSON.parse(fs.readFileSync(path.resolve(patchFlag), 'utf8'));
4881
+ } catch (e) {
4882
+ process.stderr.write(`agentic-security governance propose-edit: could not read/parse --patch file "${patchFlag}": ${e.message}\n`);
4883
+ return 2;
4884
+ }
4885
+
4886
+ const { RECIPIENT_CONFIG_FILENAME } = await import('../src/lineage/recipient-registry.js');
4887
+ const { statePath } = await import('../src/posture/state-dir.js');
4888
+ const configPath = statePath(targetAbs, RECIPIENT_CONFIG_FILENAME);
4889
+ const currentRaw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '{"recipients":{}}';
4890
+ const currentDigest = crypto.createHash('sha256').update(currentRaw).digest('hex');
4891
+ // The merge base is the file's REAL bytes, parsed directly — never
4892
+ // loadRecipientConfig's sanitized view, which silently drops any
4893
+ // entry failing isValidRecipientConfigEntry, any non-string/empty
4894
+ // key, and any top-level key other than `recipients`. Merging against
4895
+ // that sanitized object and writing it back permanently deletes
4896
+ // whatever it dropped (B1, the final review's Blocking finding), with
4897
+ // the preview and audit event both falsely reporting nothing removed.
4898
+ // This single JSON.parse is now the ONLY read of the current file, so
4899
+ // currentDigest and the merge base provably describe the same bytes
4900
+ // (also closes M3).
4901
+ let currentConfig;
4902
+ try {
4903
+ currentConfig = JSON.parse(currentRaw);
4904
+ } catch (e) {
4905
+ process.stderr.write(`agentic-security governance propose-edit: the current config file is not valid JSON: ${e.message}\n`);
4906
+ return 2;
4907
+ }
4908
+
4909
+ // Version guard runs BEFORE validation and BEFORE any write — a
4910
+ // concurrent-edit rejection must never partially validate or
4911
+ // partially write first.
4912
+ const baseDigestFlag = args.flags['base-digest'];
4913
+ if (baseDigestFlag && baseDigestFlag !== currentDigest) {
4914
+ process.stderr.write(`agentic-security governance propose-edit: the config file changed since --base-digest was computed (a concurrent edit) — refusing to write. Re-read the current file and recompute your patch.\n`);
4915
+ return 2;
4916
+ }
4917
+
4918
+ const { proposeGovernanceEdit } = await import('../src/lineage/governance-edit.js');
4919
+ const { valid, errors, diff, merged } = proposeGovernanceEdit(currentConfig, patch);
4920
+ if (!valid) {
4921
+ process.stderr.write(`agentic-security governance propose-edit: --patch file failed validation:\n${errors.map((e) => ` ${e.key}: ${e.message}`).join('\n')}\n`);
4922
+ return 1;
4923
+ }
4924
+
4925
+ const yes = !!args.flags.yes;
4926
+ let written = false;
4927
+ let backupPath = null;
4928
+ if (yes) {
4929
+ // Refuse before touching disk if the target doesn't look like a real
4930
+ // project directory — the same guard every other write path in this
4931
+ // file uses, applied here before any mkdirSync/backup/write (M5).
4932
+ const { isSafeStateDir } = await import('../src/posture/state-dir.js');
4933
+ if (!isSafeStateDir(path.dirname(configPath))) {
4934
+ process.stderr.write(`agentic-security governance propose-edit: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
4935
+ return 2;
4936
+ }
4937
+ // Backup BEFORE the new content is written — a failed write below
4938
+ // this point leaves the backup intact and the original untouched.
4939
+ // Only recorded when a backup actually happened (i.e. a prior file
4940
+ // existed) — reporting a backup path that was never written would
4941
+ // mislead a consumer trusting the report. Backups live in their own
4942
+ // dedicated subdirectory (mirroring posture/fix-history.js's own
4943
+ // `fix-history/` precedent), never as a sibling `.bak-*` file next
4944
+ // to the config — that's what lets artifact-registry.js register the
4945
+ // WHOLE DIRECTORY as one entry (I5) so `reset` can sweep it, since
4946
+ // the registry only supports exact-name matches, never a per-file
4947
+ // timestamped name. The `Date.now()-<random>` naming (mirroring this
4948
+ // file's own temp-file naming convention in `_writeConfigAtomic`)
4949
+ // closes M6 — two writes in the same millisecond no longer collide.
4950
+ const backupDir = statePath(targetAbs, 'recipient-profiles-backups');
4951
+ const candidateBackupPath = path.join(backupDir, `${Date.now()}-${crypto.randomBytes(4).toString('hex')}.bak`);
4952
+ if (fs.existsSync(configPath)) {
4953
+ fs.mkdirSync(backupDir, { recursive: true });
4954
+ fs.copyFileSync(configPath, candidateBackupPath);
4955
+ backupPath = candidateBackupPath;
4956
+ }
4957
+ // The write is the MERGE RESULT (`merged`, per proposeGovernanceEdit's
4958
+ // merge-patch semantics), never the raw patch — writing the patch
4959
+ // verbatim would silently delete every recipient it doesn't name.
4960
+ // `merged` is never null here — a null merge base only happens on a
4961
+ // container-shape validation failure, which already returned 1 above.
4962
+ await _writeConfigAtomic(configPath, JSON.stringify(merged, null, 2));
4963
+ written = true;
4964
+ // Audited ONLY on the real write path — --yes supplied AND
4965
+ // validation passed AND the version guard passed. Never on a
4966
+ // dry-run preview, never on a validation failure. Carries the
4967
+ // pre-write digest and the backup path so a later auditor can tell
4968
+ // which bytes this event produced (M2).
4969
+ const { auditCall } = await import('../src/mcp/audit.js');
4970
+ auditCall({
4971
+ sessionRoot: targetAbs, tool: 'governance_propose_edit',
4972
+ args: {
4973
+ file: RECIPIENT_CONFIG_FILENAME, added: diff.added, removed: diff.removed,
4974
+ changedKeys: diff.changed.map((c) => c.key), beforeDigest: currentDigest, backupPath,
4975
+ },
4976
+ outcome: 'ok',
4977
+ });
4978
+ }
4979
+
4980
+ const report = { currentDigest, diff, written, backupPath };
4981
+ const outputPath = args.flags.output;
4982
+ if (outputPath) {
4983
+ fs.writeFileSync(path.resolve(outputPath), JSON.stringify(report, null, 2));
4984
+ } else {
4985
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
4986
+ }
4987
+ return 0;
4988
+ }
4989
+
4990
+ // agentic-security federate declare [path] --local-node <node-id>
4991
+ // --remote-graph <file> --remote-node <node-id> [--repository <label>]
4992
+ // [--relationship data_flow] [--rationale <text>] [--output <file>]
4993
+ // [--yes] [--base-digest <hex>] — M5 deliverable #8 (FR-304's "declared"
4994
+ // half). Declares a CrossRepoLink between a node in the CURRENT
4995
+ // locally-scanned graph and a node in a REMOTE graph export
4996
+ // (`dataflow export --format json`'s own artifact, loaded via
4997
+ // federation-loader.js's loadRemoteGraphExport — never loadSignedGraph,
4998
+ // which authenticates against a per-install HMAC key, the wrong trust
4999
+ // model for a file that crossed a repo/machine boundary).
5000
+ //
5001
+ // Reuses cmdGovernancePropose's exact write contract: (1) version guard
5002
+ // on cross-repo-links.json BEFORE any read of the remote file or any
5003
+ // validation; (2) loads+validates the remote export (a digest-mismatch
5004
+ // is a printed warning, never silently swallowed, and never blocks
5005
+ // --yes — the operator is explicitly asserting this file); (3) confirms
5006
+ // --local-node exists in the CURRENT locally-scanned graph and
5007
+ // --remote-node exists in the loaded remote export's own nodes[];
5008
+ // (4) on --yes: backup, atomic write (via the already-shipped
5009
+ // _writeConfigAtomic), a real hash-chained audit event. Exit codes
5010
+ // mirror cmdGovernancePropose's own scheme: 0 success (incl. preview),
5011
+ // 1 validation failure, 2 usage/version-guard/node-not-found, 4 an
5012
+ // unexpected I/O error during the write itself — uncaught, falling
5013
+ // through to main()'s own outer catch/process.exit(4), the identical,
5014
+ // deliberate non-pattern cmdGovernancePropose itself relies on (no local
5015
+ // try/catch here either).
5016
+ async function cmdFederateDeclare(args) {
5017
+ const target = args._[2] || '.'; // args._ = ['federate', 'declare', <path>?]
5018
+ const targetAbs = path.resolve(target);
5019
+
5020
+ const localNodeFlag = args.flags['local-node'];
5021
+ const remoteGraphFlag = args.flags['remote-graph'];
5022
+ const remoteNodeFlag = args.flags['remote-node'];
5023
+ if (!localNodeFlag || typeof localNodeFlag !== 'string') {
5024
+ process.stderr.write('agentic-security federate declare: --local-node <node-id> is required.\n');
5025
+ return 2;
5026
+ }
5027
+ if (!remoteGraphFlag || typeof remoteGraphFlag !== 'string') {
5028
+ process.stderr.write('agentic-security federate declare: --remote-graph <file> is required.\n');
5029
+ return 2;
5030
+ }
5031
+ if (!remoteNodeFlag || typeof remoteNodeFlag !== 'string') {
5032
+ process.stderr.write('agentic-security federate declare: --remote-node <node-id> is required.\n');
5033
+ return 2;
5034
+ }
5035
+
5036
+ const { CROSS_REPO_LINK_VERSION, CROSS_REPO_LINKS_FILENAME, CROSS_REPO_LINK_RELATIONSHIP, validateCrossRepoLink } = await import('../src/lineage/cross-repo-link.js');
5037
+
5038
+ const relationshipFlag = args.flags.relationship ?? CROSS_REPO_LINK_RELATIONSHIP;
5039
+ if (relationshipFlag !== CROSS_REPO_LINK_RELATIONSHIP) {
5040
+ process.stderr.write(`agentic-security federate declare: --relationship must be "${CROSS_REPO_LINK_RELATIONSHIP}" (got "${relationshipFlag}") — no other relationship value is defined.\n`);
5041
+ return 2;
5042
+ }
5043
+
5044
+ const { statePath, isSafeStateDir } = await import('../src/posture/state-dir.js');
5045
+ const { crossRepoLinkId } = await import('../src/lineage/ids.js');
5046
+ const { loadRemoteGraphExport } = await import('../src/lineage/federation-loader.js');
5047
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
5048
+ const { computeGraphDigest } = await import('../src/lineage/export-json.js');
5049
+
5050
+ const configPath = statePath(targetAbs, CROSS_REPO_LINKS_FILENAME);
5051
+ const currentRaw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '{"links":[]}';
5052
+ const currentDigest = crypto.createHash('sha256').update(currentRaw).digest('hex');
5053
+
5054
+ // Version guard runs BEFORE any read of the remote file or any
5055
+ // validation — mirrors cmdGovernancePropose's own ordering exactly.
5056
+ const baseDigestFlag = args.flags['base-digest'];
5057
+ if (baseDigestFlag && baseDigestFlag !== currentDigest) {
5058
+ process.stderr.write('agentic-security federate declare: the cross-repo-links file changed since --base-digest was computed (a concurrent edit) — refusing to write. Re-read the current file and recompute your declaration.\n');
5059
+ return 2;
5060
+ }
5061
+
5062
+ let currentDoc;
5063
+ try {
5064
+ currentDoc = JSON.parse(currentRaw);
5065
+ } catch (e) {
5066
+ process.stderr.write(`agentic-security federate declare: the current cross-repo-links file is not valid JSON: ${e.message}\n`);
5067
+ return 2;
5068
+ }
5069
+ if (!currentDoc || typeof currentDoc !== 'object' || Array.isArray(currentDoc) || !Array.isArray(currentDoc.links)) {
5070
+ process.stderr.write('agentic-security federate declare: the current cross-repo-links file has no "links" array (expected {"links": [...]}).\n');
5071
+ return 2;
5072
+ }
5073
+
5074
+ // Step 2: load and validate the remote export. A digest-mismatch is a
5075
+ // printed WARNING, never a blocking failure — the operator is
5076
+ // explicitly asserting this file.
5077
+ const remote = loadRemoteGraphExport(path.resolve(remoteGraphFlag));
5078
+ if (!remote.ok) {
5079
+ process.stderr.write(`agentic-security federate declare: could not load --remote-graph "${remoteGraphFlag}": ${remote.message}\n`);
5080
+ return 2;
5081
+ }
5082
+ if (!remote.digestMatches) {
5083
+ process.stderr.write(`agentic-security federate declare: WARNING — ${remote.message}\n`);
5084
+ }
5085
+ const remoteNode = (remote.graph.nodes ?? []).find((n) => n.id === remoteNodeFlag);
5086
+ if (!remoteNode) {
5087
+ process.stderr.write(`agentic-security federate declare: --remote-node "${remoteNodeFlag}" was not found in the remote export's own nodes.\n`);
5088
+ return 2;
5089
+ }
5090
+
5091
+ // Step 3: confirm --local-node exists in the CURRENT locally-scanned
5092
+ // graph — loadSignedGraph is the correct mechanism here (the LOCAL
5093
+ // side, same install, same machine).
5094
+ const local = loadSignedGraph(targetAbs);
5095
+ if (!local.ok) {
5096
+ process.stderr.write(`agentic-security federate declare: could not load the local scanned graph: ${local.message}\n`);
5097
+ return 1;
5098
+ }
5099
+ const localNode = (local.graph.nodes ?? []).find((n) => n.id === localNodeFlag);
5100
+ if (!localNode) {
5101
+ process.stderr.write(`agentic-security federate declare: --local-node "${localNodeFlag}" was not found in the current locally-scanned graph.\n`);
5102
+ return 2;
5103
+ }
5104
+
5105
+ // Strip crossRepoLinks before hashing — final whole-branch review,
5106
+ // M5 deliverable #8, B3: computeGraphDigest has no exclusion for this
5107
+ // field, so a graph's own digest otherwise depends on what
5108
+ // cross-repo links have ALREADY been declared and re-attached by a
5109
+ // later scan (graph-builder.js's own opts.crossRepoLinks hook). Left
5110
+ // unfixed, every declare-then-rescan cycle changes the digest that
5111
+ // the NEXT declaration's own id is derived from, minting a new,
5112
+ // unrelated id for the identical real-world fact. Using an object
5113
+ // without the key (not a key set to undefined) guarantees this is
5114
+ // hash-equivalent to a graph that never had the field at all,
5115
+ // regardless of how the hashing internals treat undefined values.
5116
+ const { crossRepoLinks: _omitForDigest, ...localGraphForDigest } = local.graph;
5117
+ const localGraphDigest = computeGraphDigest(localGraphForDigest);
5118
+ const idInputs = {
5119
+ localGraphId: local.graph.graphId, localGraphDigest, localNodeId: localNodeFlag,
5120
+ remoteGraphId: remote.graph.graphId, remoteGraphDigest: remote.digest, remoteNodeId: remoteNodeFlag,
5121
+ relationship: relationshipFlag,
5122
+ };
5123
+ const record = {
5124
+ id: crossRepoLinkId(idInputs),
5125
+ version: CROSS_REPO_LINK_VERSION,
5126
+ provenance: 'manual',
5127
+ relationship: relationshipFlag,
5128
+ local: { graphId: local.graph.graphId, graphDigest: localGraphDigest, nodeId: localNodeFlag },
5129
+ remote: {
5130
+ // Honest placeholder literal when the operator supplied none — no
5131
+ // code-derived signal exists to name "which repo" a bare exported
5132
+ // JSON file came from (mirrors recipient-registry.js's own
5133
+ // `graphId ?? '(no graph)'` precedent).
5134
+ repository: args.flags.repository ?? '(unspecified)',
5135
+ sourceFile: path.resolve(remoteGraphFlag),
5136
+ graphId: remote.graph.graphId, graphDigest: remote.digest, nodeId: remoteNodeFlag,
5137
+ },
5138
+ rationale: args.flags.rationale ?? null,
5139
+ declaredBy: process.env.USER || process.env.USERNAME || '(unspecified)',
5140
+ declaredAt: new Date().toISOString(),
5141
+ };
5142
+
5143
+ const { valid, errors } = validateCrossRepoLink(record);
5144
+ if (!valid) {
5145
+ process.stderr.write(`agentic-security federate declare: constructed record failed validation:\n${errors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}\n`);
5146
+ return 1;
5147
+ }
5148
+
5149
+ const yes = !!args.flags.yes;
5150
+ let written = false;
5151
+ let backupPath = null;
5152
+ if (yes) {
5153
+ if (!isSafeStateDir(path.dirname(configPath))) {
5154
+ process.stderr.write(`agentic-security federate declare: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
5155
+ return 2;
5156
+ }
5157
+ const backupDir = statePath(targetAbs, 'cross-repo-links-backups');
5158
+ const candidateBackupPath = path.join(backupDir, `${Date.now()}-${crypto.randomBytes(4).toString('hex')}.bak`);
5159
+ if (fs.existsSync(configPath)) {
5160
+ fs.mkdirSync(backupDir, { recursive: true });
5161
+ fs.copyFileSync(configPath, candidateBackupPath);
5162
+ backupPath = candidateBackupPath;
5163
+ }
5164
+ // Dedupe by id, IN PLACE — final whole-branch review, M5 deliverable
5165
+ // #8, B3, refined in round 2 per the re-review's own Minor finding:
5166
+ // a redeclare of the identical fact now replaces the existing entry
5167
+ // at its ORIGINAL array position, rather than moving it to the end
5168
+ // (round 1's filter-then-append did the latter — harmless, but an
5169
+ // unnecessary, undocumented ordering change `federate list`'s own
5170
+ // output would otherwise silently exhibit on every redeclare).
5171
+ const existingIndex = currentDoc.links.findIndex((l) => l && l.id === record.id);
5172
+ const newLinks = existingIndex === -1
5173
+ ? [...currentDoc.links, record]
5174
+ : currentDoc.links.map((l, i) => (i === existingIndex ? record : l));
5175
+ const merged = { ...currentDoc, links: newLinks };
5176
+ await _writeConfigAtomic(configPath, JSON.stringify(merged, null, 2));
5177
+ written = true;
5178
+ const { auditCall } = await import('../src/mcp/audit.js');
5179
+ auditCall({
5180
+ sessionRoot: targetAbs, tool: 'federate_declare',
5181
+ args: {
5182
+ file: CROSS_REPO_LINKS_FILENAME, id: record.id, localNodeId: localNodeFlag, remoteNodeId: remoteNodeFlag,
5183
+ digestMatches: remote.digestMatches, beforeDigest: currentDigest, backupPath,
5184
+ },
5185
+ outcome: 'ok',
5186
+ });
5187
+ }
5188
+
5189
+ const report = { currentDigest, record, digestMatches: remote.digestMatches, written, backupPath };
5190
+ const outputPath = args.flags.output;
5191
+ if (outputPath) {
5192
+ fs.writeFileSync(path.resolve(outputPath), JSON.stringify(report, null, 2));
5193
+ } else {
5194
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
5195
+ }
5196
+ return 0;
5197
+ }
5198
+
5199
+ // agentic-security federate list [path] [--output <file>] — M5
5200
+ // deliverable #8. Read-only. Reads cross-repo-links.json and, for each
5201
+ // entry, reports whether local.nodeId still resolves against the
5202
+ // current loadSignedGraph output and whether remote.sourceFile still
5203
+ // exists/parses/digest-matches/still names the declared remote node —
5204
+ // never fabricates "still valid" when it cannot check (mirrors
5205
+ // `dataflow observations list`'s own precedent). Exit codes: 0 success
5206
+ // (including an empty list), 2 a malformed cross-repo-links.json.
5207
+ async function cmdFederateList(args) {
5208
+ const target = args._[2] || '.'; // args._ = ['federate', 'list', <path>?]
5209
+ const targetAbs = path.resolve(target);
5210
+
5211
+ const { statePath } = await import('../src/posture/state-dir.js');
5212
+ const { CROSS_REPO_LINKS_FILENAME } = await import('../src/lineage/cross-repo-link.js');
5213
+ const { loadRemoteGraphExport } = await import('../src/lineage/federation-loader.js');
5214
+ const { loadSignedGraph } = await import('../src/server/graph-loader.js');
5215
+
5216
+ const configPath = statePath(targetAbs, CROSS_REPO_LINKS_FILENAME);
5217
+ let links = [];
5218
+ if (fs.existsSync(configPath)) {
5219
+ let doc;
5220
+ try {
5221
+ doc = JSON.parse(fs.readFileSync(configPath, 'utf8'));
5222
+ } catch (e) {
5223
+ process.stderr.write(`agentic-security federate list: ${configPath} is not valid JSON: ${e.message}\n`);
5224
+ return 2;
5225
+ }
5226
+ links = Array.isArray(doc?.links) ? doc.links : [];
5227
+ }
5228
+
5229
+ const local = loadSignedGraph(targetAbs);
5230
+ const localNodeIds = local.ok ? new Set((local.graph.nodes ?? []).map((n) => n.id)) : null;
5231
+
5232
+ const results = links.map((record) => {
5233
+ // `stillValid` is null — "could not check" — whenever there is no
5234
+ // current local graph to check against, never fabricated as true or
5235
+ // false.
5236
+ const localStillValid = localNodeIds ? localNodeIds.has(record?.local?.nodeId) : null;
5237
+
5238
+ let remoteStatus;
5239
+ const sourceFile = record?.remote?.sourceFile;
5240
+ if (typeof sourceFile !== 'string' || !sourceFile) {
5241
+ remoteStatus = { checked: false, reason: 'no sourceFile recorded on this record' };
5242
+ } else {
5243
+ const remote = loadRemoteGraphExport(sourceFile);
5244
+ if (!remote.ok) {
5245
+ remoteStatus = { checked: true, ok: false, reason: remote.reason, message: remote.message };
5246
+ } else {
5247
+ const nodeStillPresent = (remote.graph.nodes ?? []).some((n) => n.id === record?.remote?.nodeId);
5248
+ remoteStatus = { checked: true, ok: true, digestMatches: remote.digestMatches, nodeStillPresent };
5249
+ }
5250
+ }
5251
+
5252
+ return {
5253
+ id: record?.id ?? null,
5254
+ local: { nodeId: record?.local?.nodeId ?? null, stillValid: localStillValid },
5255
+ remote: { sourceFile: sourceFile ?? null, nodeId: record?.remote?.nodeId ?? null, ...remoteStatus },
5256
+ rationale: record?.rationale ?? null,
5257
+ declaredBy: record?.declaredBy ?? null,
5258
+ declaredAt: record?.declaredAt ?? null,
5259
+ };
5260
+ });
5261
+
5262
+ const report = { links: results, localGraphAvailable: local.ok };
5263
+ const outputPath = args.flags.output;
5264
+ if (outputPath) {
5265
+ fs.writeFileSync(path.resolve(outputPath), JSON.stringify(report, null, 2));
5266
+ } else {
5267
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
5268
+ }
5269
+ return 0;
5270
+ }
5271
+
5272
+ // ── remediation open/update/accept-risk/list — M5 deliverable #6
5273
+ // (Blast-Radius: Remediation Command Center, FR-507 + AC-31), Task 3.
5274
+ // The CLI wiring for the non-GraphDiff-dependent half of the `remediation`
5275
+ // dispatcher (Task 4 adds `verify`/`reopen-check`). Same shape as
5276
+ // `cmdGovernancePropose` above: dry-run-by-default, `--yes` to write,
5277
+ // `isSafeStateDir` guard, `auditCall` on every real write, exit codes
5278
+ // 0/1/2/4 — but writing through Task 2's `appendLedgerEvent` (an
5279
+ // append-only JSONL event log) rather than a whole-file rewrite.
5280
+
5281
+ function _emitRemediationReport(report, args) {
5282
+ const data = JSON.stringify(report, null, 2);
5283
+ const outputPath = args.flags.output;
5284
+ if (outputPath) {
5285
+ fs.writeFileSync(path.resolve(outputPath), data);
5286
+ } else {
5287
+ process.stdout.write(data + '\n');
5288
+ }
5289
+ }
5290
+
5291
+ // Shared write path for every mutating `remediation` verb. Order is the
5292
+ // same one cmdGovernancePropose established and is load-bearing:
5293
+ // 1. --base-event optimistic-concurrency guard (BEFORE any validation
5294
+ // or write) — the append-only analogue of #5's whole-file
5295
+ // --base-digest, keyed to the last EVENT hash per the scoping doc's
5296
+ // §3 "reuse this shape, with one genuine adaptation."
5297
+ // 2. isSafeStateDir refusal (--yes path only).
5298
+ // 3. Dry run: fold the CURRENT ledger, report what WOULD happen, write
5299
+ // nothing, exit 0 (or 1 if the proposed event is itself illegal).
5300
+ // 4. --yes: appendLedgerEvent (which is the ONLY caller of
5301
+ // validateTransition), then auditCall — never the reverse, and
5302
+ // never auditCall on a rejected or previewed write.
5303
+ // `extraReport` (Task 4) is an optional object of additional fields merged
5304
+ // into the emitted report, in BOTH the dry-run-preview and the real-write
5305
+ // branches — e.g. cmdRemediationVerify's own beforeSnapshotId/
5306
+ // afterSnapshotId/beforeSnapshotSource/evidenceOutcome. It is never
5307
+ // consulted for validity — only validateTransition (inside
5308
+ // appendLedgerEvent, or this function's own preview call below) decides
5309
+ // that — so a caller cannot use it to smuggle a write past validation.
5310
+ // Returns an exit code; the caller returns it verbatim.
5311
+ async function _remediationWrite(targetAbs, verb, auditTool, eventPayload, args, extraReport = {}) {
5312
+ const { ledgerPaths, latestEventHash, readLedgerEvents, appendLedgerEvent } =
5313
+ await import('../src/posture/remediation-ledger.js');
5314
+ const { foldRemediationLedger, validateTransition } = await import('../src/lineage/remediation.js');
5315
+
5316
+ const { ledgerPath } = ledgerPaths(targetAbs);
5317
+ const itemId = eventPayload.itemId ?? eventPayload.id ?? null;
5318
+
5319
+ // 1. --base-event guard — before any validation or write.
5320
+ const currentBaseEvent = latestEventHash(targetAbs);
5321
+ const baseEventFlag = args.flags['base-event'];
5322
+ if (baseEventFlag !== undefined && baseEventFlag !== currentBaseEvent) {
5323
+ process.stderr.write(`agentic-security remediation ${verb}: the ledger changed since --base-event was computed (a concurrent write) — refusing to append. Re-read the ledger with \`remediation list\` and retry.\n`);
5324
+ return 2;
5325
+ }
5326
+
5327
+ const yes = !!args.flags.yes;
5328
+
5329
+ // 2. isSafeStateDir refusal — --yes path only.
5330
+ if (yes) {
5331
+ const { isSafeStateDir } = await import('../src/posture/state-dir.js');
5332
+ if (!isSafeStateDir(path.dirname(ledgerPath))) {
5333
+ process.stderr.write(`agentic-security remediation ${verb}: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
5334
+ return 2;
5335
+ }
5336
+ }
5337
+
5338
+ // 3. Dry run — a genuine preview of the resulting state, computed by
5339
+ // folding the CURRENT ledger with the proposed event appended in
5340
+ // memory. The proposed event's own legality is checked here too (for
5341
+ // the preview only — the authoritative call remains the one inside
5342
+ // appendLedgerEvent below), so --yes never surprises an operator who
5343
+ // previewed first.
5344
+ if (!yes) {
5345
+ const events = readLedgerEvents(targetAbs);
5346
+ const currentItems = foldRemediationLedger(events);
5347
+ const currentItem = itemId != null ? (currentItems[itemId] ?? null) : null;
5348
+ const wouldBeItems = foldRemediationLedger([...events, eventPayload]);
5349
+ const wouldBe = itemId != null ? (wouldBeItems[itemId] ?? null) : null;
5350
+ const report = { verb, itemId, proposedEvent: eventPayload, baseEvent: currentBaseEvent, written: false, wouldBe, ...extraReport };
5351
+ const { valid, errors } = validateTransition(currentItem, eventPayload);
5352
+ if (!valid) {
5353
+ report.errors = errors;
5354
+ _emitRemediationReport(report, args);
5355
+ for (const e of errors) process.stderr.write(`agentic-security remediation ${verb}: ${e.field}: ${e.message}\n`);
5356
+ return 1;
5357
+ }
5358
+ _emitRemediationReport(report, args);
5359
+ return 0;
5360
+ }
5361
+
5362
+ // 4. --yes: the real write. `expectedBaseHash` (I5) threads the
5363
+ // --base-event guard through to the authoritative, inside-the-lock
5364
+ // check — the pre-lock check above (#1) remains as a cheap early fail,
5365
+ // but this is the one that cannot be raced by a concurrent writer.
5366
+ let result;
5367
+ try {
5368
+ result = await appendLedgerEvent(targetAbs, eventPayload, { expectedBaseHash: baseEventFlag });
5369
+ } catch (e) {
5370
+ process.stderr.write(`agentic-security remediation ${verb}: unexpected error writing the ledger: ${e && e.message ? e.message : e}\n`);
5371
+ return 4;
5372
+ }
5373
+ if (!result.valid) {
5374
+ for (const e of result.errors) process.stderr.write(`agentic-security remediation ${verb}: ${e.field}: ${e.message}\n`);
5375
+ // M10: a state-writes-disabled or unsafe-scanRoot refusal is a
5376
+ // usage/environment condition, not a rejected state transition —
5377
+ // `commands/remediation.md`'s exit-code table reserves 1 for the
5378
+ // latter. Both error fields are environment refusals emitted by
5379
+ // appendLedgerEvent BEFORE any real validation runs; every other
5380
+ // error field is a genuine validation/state-machine rejection.
5381
+ if (result.errors.some((e) => e.field === '(state)' || e.field === '(scanRoot)')) return 2;
5382
+ return 1;
5383
+ }
5384
+ const { auditCall } = await import('../src/mcp/audit.js');
5385
+ auditCall({
5386
+ sessionRoot: targetAbs,
5387
+ tool: auditTool,
5388
+ args: { itemId, eventType: eventPayload.type, baseEvent: currentBaseEvent, eventHash: result.hash },
5389
+ outcome: 'ok',
5390
+ });
5391
+ const report = { verb, itemId, proposedEvent: eventPayload, baseEvent: currentBaseEvent, written: true, eventHash: result.hash, ...extraReport };
5392
+ _emitRemediationReport(report, args);
5393
+ return 0;
5394
+ }
5395
+
5396
+ // agentic-security remediation open [path] --assessment <impact-report.json>
5397
+ // --owner <id> --due <YYYY-MM-DD> --control <text>
5398
+ // --required-evidence <flowId,...> [--id <itemId>] [--snapshot <commit>]
5399
+ // [--allow-manual-attestation] [--output <file>] [--yes]
5400
+ async function cmdRemediationOpen(args) {
5401
+ const target = args._[2] || '.'; // args._ = ['remediation', 'open', <path>?]
5402
+ const targetAbs = path.resolve(target);
5403
+
5404
+ const assessmentFlag = args.flags.assessment;
5405
+ const ownerFlag = args.flags.owner;
5406
+ const dueFlag = args.flags.due;
5407
+ const controlFlag = args.flags.control;
5408
+ const requiredEvidenceFlag = args.flags['required-evidence'];
5409
+
5410
+ for (const [name, val] of [
5411
+ ['--assessment', assessmentFlag], ['--owner', ownerFlag], ['--due', dueFlag],
5412
+ ['--control', controlFlag], ['--required-evidence', requiredEvidenceFlag],
5413
+ ]) {
5414
+ if (!val || typeof val !== 'string') {
5415
+ process.stderr.write(`agentic-security remediation open: ${name} is required.\n`);
5416
+ return 2;
5417
+ }
5418
+ }
5419
+
5420
+ // This is the RAW `dataflow impact assess --format json --output`
5421
+ // report — cmdDataflowImpactAssess writes JSON.stringify(record, null,
5422
+ // 2), so the file on disk IS the record. No hand-extraction needed.
5423
+ let record;
5424
+ try {
5425
+ record = JSON.parse(fs.readFileSync(path.resolve(assessmentFlag), 'utf8'));
5426
+ } catch (e) {
5427
+ process.stderr.write(`agentic-security remediation open: could not read/parse --assessment file "${assessmentFlag}": ${e && e.message ? e.message : e}\n`);
5428
+ return 2;
5429
+ }
5430
+ const { validateImpactAssessment } = await import('../src/lineage/impact-assessment.js');
5431
+ const { valid: assessmentValid, errors: assessmentErrors } = validateImpactAssessment(record);
5432
+ if (!assessmentValid) {
5433
+ process.stderr.write(`agentic-security remediation open: --assessment file failed validation:\n${assessmentErrors.map((e) => ` ${e.path}: ${e.message}`).join('\n')}\n`);
5434
+ return 2;
5435
+ }
5436
+
5437
+ // Resolve the incident snapshot: --snapshot <commit> pins one specific
5438
+ // persisted snapshot; otherwise the newest one is used. Refuses (exit
5439
+ // 2) when no snapshot exists at all — an item with no incident
5440
+ // snapshot can never be verified, so opening one would be a
5441
+ // guaranteed dead end.
5442
+ const snapshotFlag = args.flags.snapshot;
5443
+ let snapshot;
5444
+ if (snapshotFlag !== undefined) {
5445
+ snapshot = loadSnapshot(targetAbs, snapshotFlag);
5446
+ if (!snapshot) {
5447
+ process.stderr.write(`agentic-security remediation open: no persisted GraphSnapshot found for commit "${snapshotFlag}" — pass a commit that was actually scanned.\n`);
5448
+ return 2;
5449
+ }
5450
+ } else {
5451
+ const snapshots = loadSnapshots(targetAbs);
5452
+ if (snapshots.length === 0) {
5453
+ process.stderr.write(`agentic-security remediation open: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
5454
+ return 2;
5455
+ }
5456
+ snapshot = snapshots[0];
5457
+ }
5458
+
5459
+ const requiredEvidence = String(requiredEvidenceFlag).split(',').map((s) => s.trim()).filter(Boolean);
5460
+ if (requiredEvidence.length === 0) {
5461
+ process.stderr.write('agentic-security remediation open: --required-evidence must name at least one flow id.\n');
5462
+ return 2;
5463
+ }
5464
+
5465
+ const itemId = args.flags.id || `rem-${crypto.randomBytes(6).toString('hex')}`;
5466
+ // affectedFlowIds is DERIVED, not copied — ImpactAssessment carries no
5467
+ // affectedFlowIds field of its own (impact-assessment.js). The sorted,
5468
+ // deduplicated union of --required-evidence and (the target itself,
5469
+ // when it names a flow) is the only real flow-id source available.
5470
+ const affectedFlowIds = [...new Set([
5471
+ ...requiredEvidence,
5472
+ ...(typeof record.targetId === 'string' && record.targetId.startsWith('flow:') ? [record.targetId] : []),
5473
+ ])].sort();
5474
+
5475
+ const payload = {
5476
+ type: 'opened',
5477
+ at: new Date().toISOString(),
5478
+ itemId,
5479
+ id: itemId,
5480
+ owner: ownerFlag,
5481
+ dueDate: dueFlag,
5482
+ recommendedControl: controlFlag,
5483
+ assessment: {
5484
+ assessmentId: record.id,
5485
+ targetId: record.targetId,
5486
+ targetKind: record.targetKind,
5487
+ traceKind: record.traceKind,
5488
+ scope: record.scope,
5489
+ graphId: record.graphId,
5490
+ graphDigest: record.graphDigest,
5491
+ snapshotId: snapshot.id,
5492
+ assessmentPath: path.resolve(assessmentFlag),
5493
+ },
5494
+ affectedFlowIds,
5495
+ affectedNodeIds: record.affectedNodeIds ?? [],
5496
+ affectedEdgeIds: record.affectedEdgeIds ?? [],
5497
+ requiredEvidence,
5498
+ manualAttestationPermitted: !!args.flags['allow-manual-attestation'],
5499
+ };
5500
+
5501
+ const { validateOpenPayload } = await import('../src/lineage/remediation.js');
5502
+ const { valid, errors } = validateOpenPayload(payload);
5503
+ if (!valid) {
5504
+ process.stderr.write(`agentic-security remediation open: ${errors.map((e) => `${e.field}: ${e.message}`).join('\n')}\n`);
5505
+ return 1;
5506
+ }
5507
+
5508
+ return _remediationWrite(targetAbs, 'open', 'remediation_open', payload, args);
5509
+ }
5510
+
5511
+ // agentic-security remediation update [path] --id <itemId>
5512
+ // --state <in_progress|awaiting_verification> [--base-event <hash>]
5513
+ // [--output <file>] [--yes]
5514
+ async function cmdRemediationUpdate(args) {
5515
+ const target = args._[2] || '.'; // args._ = ['remediation', 'update', <path>?]
5516
+ const targetAbs = path.resolve(target);
5517
+
5518
+ const idFlag = args.flags.id;
5519
+ if (!idFlag || typeof idFlag !== 'string') {
5520
+ process.stderr.write('agentic-security remediation update: --id <itemId> is required.\n');
5521
+ return 2;
5522
+ }
5523
+ const stateFlag = args.flags.state;
5524
+ if (!stateFlag || typeof stateFlag !== 'string') {
5525
+ process.stderr.write('agentic-security remediation update: --state <in_progress|awaiting_verification> is required.\n');
5526
+ return 2;
5527
+ }
5528
+ // AC-31 at the CLI boundary: `state_changed` can never reach `verified`
5529
+ // — this is the friendlier of two independent guards for the one rule
5530
+ // that matters most (the ledger's own validateTransition rejection,
5531
+ // enforced inside appendLedgerEvent, is the one that cannot be
5532
+ // bypassed). Checked BEFORE the recognized-values check below so a
5533
+ // caller gets this specific message rather than a generic usage error.
5534
+ if (stateFlag === 'verified') {
5535
+ process.stderr.write('agentic-security remediation update: state_changed can never reach "verified" — run `agentic-security remediation verify` instead.\n');
5536
+ return 1;
5537
+ }
5538
+ if (stateFlag !== 'in_progress' && stateFlag !== 'awaiting_verification') {
5539
+ process.stderr.write(`agentic-security remediation update: --state must be one of in_progress|awaiting_verification (got ${JSON.stringify(stateFlag)}).\n`);
5540
+ return 2;
5541
+ }
5542
+
5543
+ const payload = { type: 'state_changed', at: new Date().toISOString(), itemId: idFlag, state: stateFlag };
5544
+ return _remediationWrite(targetAbs, 'update', 'remediation_update', payload, args);
5545
+ }
5546
+
5547
+ // agentic-security remediation accept-risk [path] --id <itemId>
5548
+ // --approver <id> --reason <text> --scope <text> --expires <YYYY-MM-DD>
5549
+ // [--author <id>] [--base-event <hash>] [--output <file>] [--yes]
5550
+ async function cmdRemediationAcceptRisk(args) {
5551
+ const target = args._[2] || '.'; // args._ = ['remediation', 'accept-risk', <path>?]
5552
+ const targetAbs = path.resolve(target);
5553
+
5554
+ const idFlag = args.flags.id;
5555
+ const approverFlag = args.flags.approver;
5556
+ const reasonFlag = args.flags.reason;
5557
+ const scopeFlag = args.flags.scope;
5558
+ const expiresFlag = args.flags.expires;
5559
+
5560
+ for (const [name, val] of [
5561
+ ['--id', idFlag], ['--approver', approverFlag], ['--reason', reasonFlag],
5562
+ ['--scope', scopeFlag], ['--expires', expiresFlag],
5563
+ ]) {
5564
+ if (!val || typeof val !== 'string') {
5565
+ process.stderr.write(`agentic-security remediation accept-risk: ${name} is required.\n`);
5566
+ return 2;
5567
+ }
5568
+ }
5569
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(expiresFlag)) {
5570
+ process.stderr.write(`agentic-security remediation accept-risk: --expires must be a YYYY-MM-DD date (got ${JSON.stringify(expiresFlag)}).\n`);
5571
+ return 2;
5572
+ }
5573
+
5574
+ const { loadApproverRegistry, verifyApprover, checkSeparationOfDuties } =
5575
+ await import('../src/fix/approver-registry.js');
5576
+ const registry = loadApproverRegistry(targetAbs);
5577
+ const v = verifyApprover(registry, approverFlag, []);
5578
+ if (!v.verified) {
5579
+ process.stderr.write(`agentic-security remediation accept-risk: ${v.reason}\n`);
5580
+ return 1;
5581
+ }
5582
+ const sod = checkSeparationOfDuties(registry, args.flags.author, approverFlag);
5583
+ if (!sod.ok) {
5584
+ process.stderr.write(`agentic-security remediation accept-risk: ${sod.reason}\n`);
5585
+ return 1;
5586
+ }
5587
+
5588
+ const payload = {
5589
+ type: 'accepted_risk',
5590
+ at: new Date().toISOString(),
5591
+ itemId: idFlag,
5592
+ approver: approverFlag,
5593
+ reason: reasonFlag,
5594
+ scope: scopeFlag,
5595
+ expiration: expiresFlag,
5596
+ };
5597
+ return _remediationWrite(targetAbs, 'accept-risk', 'remediation_accept_risk', payload, args);
5598
+ }
5599
+
5600
+ // Byte-identical in behavior to _dfDiffMdInline/_dfDiffMdCell's own bodies
5601
+ // (bin/agentic-security.js), reimplemented locally per this codebase's
5602
+ // established per-module-owns-its-own-escaping-helpers convention.
5603
+ function _remMdInline(value) {
5604
+ return String(value).replace(/\r\n|\r|\n/g, ' ');
5605
+ }
5606
+ function _remMdCell(value) {
5607
+ return _remMdInline(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
5608
+ }
5609
+
5610
+ function _renderRemediationListMarkdown(items, integrity) {
5611
+ const lines = [];
5612
+ if (integrity && !integrity.ok) {
5613
+ lines.push(`> **WARNING — ledger integrity check failed**: the ledger's real content has ${integrity.totalLines} lines but only ${integrity.verifiedLines} verify; the list below may be missing recent items or reflect a tampered/truncated history. Do not treat it as complete.`);
5614
+ lines.push('');
5615
+ }
5616
+ if (items.length === 0) {
5617
+ lines.push('_no remediation items_');
5618
+ return lines.join('\n') + '\n';
5619
+ }
5620
+ lines.push('| id | state | owner | dueDate | recommendedControl |');
5621
+ lines.push('| --- | --- | --- | --- | --- |');
5622
+ for (const item of items) {
5623
+ const control = String(item.recommendedControl ?? '');
5624
+ const truncated = control.length > 60 ? control.slice(0, 60) + '…' : control;
5625
+ lines.push(`| ${_remMdCell(item.id)} | ${_remMdCell(item.state)} | ${_remMdCell(item.owner)} | ${_remMdCell(item.dueDate)} | ${_remMdCell(truncated)} |`);
5626
+ }
5627
+ return lines.join('\n') + '\n';
5628
+ }
5629
+
5630
+ // agentic-security remediation list [path] [--format json|markdown] [--output <file>]
5631
+ async function cmdRemediationList(args) {
5632
+ const target = args._[2] || '.'; // args._ = ['remediation', 'list', <path>?]
5633
+ const targetAbs = path.resolve(target);
5634
+
5635
+ const format = args.flags.format ?? 'json';
5636
+ if (format !== 'json' && format !== 'markdown') {
5637
+ process.stderr.write(`agentic-security remediation list: --format must be one of json|markdown (got ${JSON.stringify(format)}).\n`);
5638
+ return 2;
5639
+ }
5640
+
5641
+ const { readLedgerEvents, ledgerIntegrity } = await import('../src/posture/remediation-ledger.js');
5642
+ const { foldRemediationLedger } = await import('../src/lineage/remediation.js');
5643
+ const items = Object.values(foldRemediationLedger(readLedgerEvents(targetAbs)))
5644
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
5645
+
5646
+ // I7: a tampered/truncated ledger otherwise silently presents a
5647
+ // shorter-than-real history with no signal anywhere. `ledgerIntegrity`
5648
+ // is a read-only diagnostic — it never changes `items` itself (already
5649
+ // computed from the same longest-verifying-prefix `readLedgerEvents`
5650
+ // always returned) — it just surfaces the loud warning this command
5651
+ // previously had no way to give.
5652
+ const integrity = ledgerIntegrity(targetAbs);
5653
+ if (!integrity.ok) {
5654
+ process.stderr.write(`agentic-security remediation list: WARNING — the ledger's real content has ${integrity.totalLines} lines but only ${integrity.verifiedLines} verify; the list below may be missing recent items or reflect a tampered/truncated history. Do not treat it as complete.\n`);
5655
+ }
5656
+
5657
+ const data = format === 'json' ? JSON.stringify({ items, integrity }, null, 2) : _renderRemediationListMarkdown(items, integrity);
5658
+ const outputPath = args.flags.output;
5659
+ if (outputPath) {
5660
+ fs.writeFileSync(path.resolve(outputPath), data);
5661
+ } else {
5662
+ process.stdout.write(data.endsWith('\n') ? data : data + '\n');
5663
+ }
5664
+ return 0;
5665
+ }
5666
+
5667
+ // ── remediation verify / reopen-check — M5 deliverable #6, Task 4. The
5668
+ // AC-31-CRITICAL half: nothing in this sub-project satisfies AC-31 until
5669
+ // these two verbs land — they are the only callers of computeGraphDiff/
5670
+ // evaluateVerificationEvidence/drift-policy.js that actually gate the
5671
+ // remediation ledger's own `verified`/`reopened` transitions.
5672
+
5673
+ // agentic-security remediation verify [path] --id <itemId> [--against <commit>]
5674
+ // [--manual-attestation --approver <id> --reason <text> [--author <id>]]
5675
+ // [--base-event <hash>] [--output <file>] [--yes]
5676
+ //
5677
+ // Two branches:
5678
+ //
5679
+ // - `--manual-attestation`: after the same approver/separation-of-duties
5680
+ // gating `accept-risk` uses, builds a manual_attestation payload and
5681
+ // delegates straight to `_remediationWrite` — NEVER computes a diff.
5682
+ // The `manualAttestationPermitted` check is `validateTransition`'s job
5683
+ // (enforced inside `appendLedgerEvent`), not duplicated here — V/7
5684
+ // proves it fires through the ledger, not through this handler.
5685
+ //
5686
+ // - default (scan-verification): resolves the AFTER snapshot exactly as
5687
+ // `cmdDataflowDiff` does (`loadSnapshots(targetAbs)[0]`; `--against
5688
+ // <commit>` is a COMMIT KEY resolved via `loadSnapshot`, never a
5689
+ // snapshot id — same resolution `cmdDataflowDiff` uses). The BEFORE
5690
+ // snapshot is preferentially the item's own incident snapshot
5691
+ // (`item.assessment.snapshotId`, resolved by scanning `loadSnapshots`
5692
+ // for a record whose `.id` matches) — that is what makes AC-31's
5693
+ // "fixed to the incident snapshot" clause true in verification too.
5694
+ // When the incident snapshot is no longer on disk, falls back to the
5695
+ // `cmdDataflowDiff` resolution (`mostRecentPriorSnapshot`), and says so
5696
+ // (`beforeSnapshotSource`) rather than silently substituting a
5697
+ // different baseline. An explicit `--against` always wins over both.
5698
+ // `computeGraphDiff` is wrapped in a try/catch — it THROWS on an
5699
+ // incomparable pair (`graph-diff.js:319`) — converted to an
5700
+ // `incomparable_snapshots` unverifiable outcome, never an abort:
5701
+ // verification was genuinely attempted and genuinely refused, and the
5702
+ // ledger records that.
5703
+ //
5704
+ // A `snapshotsComparable`-passed pair means "same schemaVersion" and
5705
+ // nothing more (`graph-snapshot.js:173`) — two snapshots from genuinely
5706
+ // different analyzer configurations are reported comparable; this is a
5707
+ // disclosed limitation, not papered over (see commands/remediation.md).
5708
+ async function cmdRemediationVerify(args) {
5709
+ const target = args._[2] || '.'; // args._ = ['remediation', 'verify', <path>?]
5710
+ const targetAbs = path.resolve(target);
5711
+
5712
+ const idFlag = args.flags.id;
5713
+ if (!idFlag || typeof idFlag !== 'string') {
5714
+ process.stderr.write('agentic-security remediation verify: --id <itemId> is required.\n');
5715
+ return 2;
5716
+ }
5717
+
5718
+ const { readLedgerEvents } = await import('../src/posture/remediation-ledger.js');
5719
+ const { foldRemediationLedger, evaluateVerificationEvidence } = await import('../src/lineage/remediation.js');
5720
+ const items = foldRemediationLedger(readLedgerEvents(targetAbs));
5721
+ const item = items[idFlag];
5722
+ if (!item) {
5723
+ process.stderr.write(`agentic-security remediation verify: no remediation item found with id "${idFlag}".\n`);
5724
+ return 1;
5725
+ }
5726
+
5727
+ // ── Manual-attestation branch. Never computes a diff. ──────────────────
5728
+ if (args.flags['manual-attestation']) {
5729
+ const approverFlag = args.flags.approver;
5730
+ const reasonFlag = args.flags.reason;
5731
+ if (!approverFlag || typeof approverFlag !== 'string' || !reasonFlag || typeof reasonFlag !== 'string') {
5732
+ process.stderr.write('agentic-security remediation verify: --manual-attestation requires --approver <id> and --reason <text>.\n');
5733
+ return 2;
5734
+ }
5735
+ const { loadApproverRegistry, verifyApprover, checkSeparationOfDuties } =
5736
+ await import('../src/fix/approver-registry.js');
5737
+ const registry = loadApproverRegistry(targetAbs);
5738
+ const v = verifyApprover(registry, approverFlag, []);
5739
+ if (!v.verified) {
5740
+ process.stderr.write(`agentic-security remediation verify: ${v.reason}\n`);
5741
+ return 1;
5742
+ }
5743
+ const sod = checkSeparationOfDuties(registry, args.flags.author, approverFlag);
5744
+ if (!sod.ok) {
5745
+ process.stderr.write(`agentic-security remediation verify: ${sod.reason}\n`);
5746
+ return 1;
5747
+ }
5748
+ if (args.flags.against !== undefined) {
5749
+ // M15: --against has no effect on this branch — it never computes a
5750
+ // diff — but a caller supplying both should be told, not left to
5751
+ // wonder why it was silently ignored.
5752
+ process.stderr.write('agentic-security remediation verify: --against has no effect with --manual-attestation (no scan comparison is performed for a manual attestation).\n');
5753
+ }
5754
+ // Resolves the newest available snapshot (if any exist yet — a manual
5755
+ // attestation before any lineage scan has ever run is legitimate, so
5756
+ // this is never required) and records it as the item's new baseline
5757
+ // (final-review fix round 1, Blocking-3) — `foldRemediationItem`'s
5758
+ // `manual_attestation` case reads `ev.snapshotId` to set
5759
+ // `verificationSnapshotId`, which is what makes the attestation
5760
+ // survive the very next `reopen-check` instead of being immediately
5761
+ // undone by a stale old anchor.
5762
+ const attestationSnapshots = loadSnapshots(targetAbs);
5763
+ const payload = {
5764
+ type: 'manual_attestation',
5765
+ at: new Date().toISOString(),
5766
+ itemId: idFlag,
5767
+ approver: approverFlag,
5768
+ reason: reasonFlag,
5769
+ ...(attestationSnapshots.length > 0 ? { snapshotId: attestationSnapshots[0].id } : {}),
5770
+ };
5771
+ return _remediationWrite(targetAbs, 'verify', 'remediation_verify', payload, args);
5772
+ }
5773
+
5774
+ // ── Scan-verification branch (the default). ─────────────────────────────
5775
+ const snapshots = loadSnapshots(targetAbs);
5776
+ if (snapshots.length === 0) {
5777
+ process.stderr.write(`agentic-security remediation verify: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
5778
+ return 2;
5779
+ }
5780
+ const afterSnapshot = snapshots[0];
5781
+
5782
+ const againstFlag = args.flags.against;
5783
+ if (againstFlag !== undefined && (typeof againstFlag !== 'string' || !againstFlag)) {
5784
+ process.stderr.write('agentic-security remediation verify: --against requires a commit value.\n');
5785
+ return 2;
5786
+ }
5787
+
5788
+ let beforeSnapshot;
5789
+ let beforeSnapshotSource;
5790
+ if (againstFlag !== undefined) {
5791
+ beforeSnapshot = loadSnapshot(targetAbs, againstFlag);
5792
+ if (!beforeSnapshot) {
5793
+ process.stderr.write(`agentic-security remediation verify: no persisted GraphSnapshot found for commit "${againstFlag}" — pass a commit that was actually scanned.\n`);
5794
+ return 2;
5795
+ }
5796
+ beforeSnapshotSource = 'against';
5797
+ } else {
5798
+ const incidentSnapshotId = item.assessment?.snapshotId;
5799
+ const incidentSnapshot = incidentSnapshotId ? snapshots.find((s) => s.id === incidentSnapshotId) : null;
5800
+ if (incidentSnapshot) {
5801
+ beforeSnapshot = incidentSnapshot;
5802
+ beforeSnapshotSource = 'incident';
5803
+ } else {
5804
+ beforeSnapshot = mostRecentPriorSnapshot(targetAbs, afterSnapshot.commit);
5805
+ beforeSnapshotSource = 'most-recent-prior';
5806
+ }
5807
+ }
5808
+
5809
+ if (!beforeSnapshot || beforeSnapshot.commit === afterSnapshot.commit) {
5810
+ process.stderr.write(`agentic-security remediation verify: an item cannot be verified until a second lineage scan exists — only one persisted GraphSnapshot is available (commit "${afterSnapshot.commit}"). Scan again after a code change, or pass --against <commit>.\n`);
5811
+ return 2;
5812
+ }
5813
+
5814
+ let evidenceOutcome;
5815
+ if (afterSnapshot.capturedAt <= beforeSnapshot.capturedAt) {
5816
+ // A real, live-reproduced Blocking bug (final review, B1): a `verified`
5817
+ // outcome must never be granted by comparing backwards. `loadSnapshots`
5818
+ // sorts by file mtime (pre-existing, out of scope to fix here), so
5819
+ // `cp -R`/rsync/a CI cache restore/tar/Docker COPY can silently reorder
5820
+ // "history" — nothing else in this deliverable's own code checks
5821
+ // direction. `capturedAt` is an ISO-8601 string on every real
5822
+ // GraphSnapshot record, so plain string comparison is chronologically
5823
+ // correct here, no `Date` parsing needed.
5824
+ evidenceOutcome = {
5825
+ outcome: 'unverifiable',
5826
+ reason: 'stale_after_snapshot',
5827
+ beforeCapturedAt: beforeSnapshot.capturedAt,
5828
+ afterCapturedAt: afterSnapshot.capturedAt,
5829
+ };
5830
+ } else {
5831
+ const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
5832
+ try {
5833
+ const diff = computeGraphDiff(beforeSnapshot, afterSnapshot);
5834
+ evidenceOutcome = evaluateVerificationEvidence(diff, item.requiredEvidence);
5835
+ } catch (e) {
5836
+ // computeGraphDiff THROWS on an incomparable pair — this is NOT an
5837
+ // exit-2 abort: verification was genuinely attempted and genuinely
5838
+ // refused, and the ledger must record why.
5839
+ evidenceOutcome = { outcome: 'unverifiable', reason: 'incomparable_snapshots', detail: e && e.message ? e.message : String(e) };
5840
+ }
5841
+ }
5842
+
5843
+ const at = new Date().toISOString();
5844
+ let payload;
5845
+ if (evidenceOutcome.outcome === 'verified') {
5846
+ payload = { type: 'scan_verification', at, itemId: idFlag, outcome: 'verified', snapshotId: afterSnapshot.id };
5847
+ } else {
5848
+ // Carry every detail field through verbatim so the ledger records WHY,
5849
+ // not just THAT — unsatisfiedFlowIds/coverageRegressionReasons/
5850
+ // reidentifiedTo/flowId/detail are all conditionally present depending
5851
+ // on which evaluateVerificationEvidence branch fired.
5852
+ payload = { type: 'scan_verification', at, itemId: idFlag, outcome: 'unverifiable', reason: evidenceOutcome.reason };
5853
+ for (const field of ['unsatisfiedFlowIds', 'coverageRegressionReasons', 'reidentifiedTo', 'flowId', 'detail']) {
5854
+ if (evidenceOutcome[field] !== undefined) payload[field] = evidenceOutcome[field];
5855
+ }
5856
+ }
5857
+
5858
+ const extraReport = {
5859
+ beforeSnapshotId: beforeSnapshot.id,
5860
+ afterSnapshotId: afterSnapshot.id,
5861
+ beforeSnapshotSource,
5862
+ evidenceOutcome,
5863
+ };
5864
+ return _remediationWrite(targetAbs, 'verify', 'remediation_verify', payload, args, extraReport);
5865
+ }
5866
+
5867
+ // agentic-security remediation reopen-check [path] [--drift-policy <file>]
5868
+ // [--against <commit>] [--output <file>] [--yes]
5869
+ //
5870
+ // AC-31's automatic-reopening half. Resolves the AFTER snapshot exactly
5871
+ // like `cmdDataflowDiff`/`cmdRemediationVerify`; `--against <commit>`
5872
+ // pins a DEFAULT/FALLBACK before-commit (never used per-item directly —
5873
+ // see below). Every item currently in state `verified` is checked
5874
+ // against TWO independently-evaluated, separately-labelled mechanisms
5875
+ // (scoping doc §4.5) — never collapsed into one unlabelled "reopened"
5876
+ // reason:
5877
+ //
5878
+ // - mechanism 'drift-policy' (Mechanism A, regression via operator
5879
+ // policy): requires `--drift-policy`; a violation whose own `flowId`
5880
+ // names one of the item's own `affectedFlowIds` is a hit.
5881
+ // - mechanism 'affected-flow-diff' (Mechanism B, control went away, a
5882
+ // direct diff read): any of the item's `affectedFlowIds` (or a flow
5883
+ // `reidentifiedFrom` one of them, I6) REAPPEARING in `diff.added.flows`
5884
+ // is a hit — final-review fix round 1's own Blocking-2 correction: a
5885
+ // scan-verified item's flagged flow was, by construction, ABSENT from
5886
+ // its own before-baseline, so a regression can only ever show up in
5887
+ // `diff.added.flows`, never `diff.removed.flows`/`diff.changed.flows`
5888
+ // (the original code read those two buckets instead, which is dead
5889
+ // for the canonical scan-verified case and fires INVERTED — reopening
5890
+ // an item because its flow successfully disappeared — for a manually-
5891
+ // attested item whose baseline still contained the flow). This exists
5892
+ // because `drift-policy.js`'s trigger vocabulary is exactly
5893
+ // `['new_flow', 'changed_flow']` — there is no `removed_flow` trigger,
5894
+ // so a control that goes away (the flow itself disappears from a
5895
+ // later scan) cannot be expressed as a drift policy at ALL.
5896
+ //
5897
+ // Disclosed limitation, not attempted in this round: a manually-
5898
+ // attested item whose flagged flow was ALREADY present at attestation
5899
+ // time (the typical manual-attestation scenario — a compensating
5900
+ // control, not flow removal) can never trigger Mechanism B this way,
5901
+ // since the flow never left `diff.added.flows`'s scope (it's already
5902
+ // present in both before/after). Such an item can only be reopened via
5903
+ // Mechanism A (`--drift-policy`).
5904
+ //
5905
+ // Per item, the BEFORE snapshot is preferentially the item's own
5906
+ // `verificationSnapshotId` (the snapshot its verification was granted
5907
+ // against — the only defensible "has it regressed since" baseline),
5908
+ // falling back to the CLI-resolved default BEFORE (`--against` /
5909
+ // `mostRecentPriorSnapshot`) when that snapshot is no longer on disk.
5910
+ //
5911
+ // No `--base-event`: this appends N events across N items, so a single
5912
+ // whole-ledger token has no coherent meaning (documented in
5913
+ // commands/remediation.md, not left for a reader to wonder about).
5914
+ async function cmdRemediationReopenCheck(args) {
5915
+ const target = args._[2] || '.'; // args._ = ['remediation', 'reopen-check', <path>?]
5916
+ const targetAbs = path.resolve(target);
5917
+
5918
+ const snapshots = loadSnapshots(targetAbs);
5919
+ if (snapshots.length === 0) {
5920
+ process.stderr.write(`agentic-security remediation reopen-check: no persisted GraphSnapshot found for "${targetAbs}" — run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first.\n`);
5921
+ return 2;
5922
+ }
5923
+ const afterSnapshot = snapshots[0];
5924
+
5925
+ const againstFlag = args.flags.against;
5926
+ if (againstFlag !== undefined && (typeof againstFlag !== 'string' || !againstFlag)) {
5927
+ process.stderr.write('agentic-security remediation reopen-check: --against requires a commit value.\n');
5928
+ return 2;
5929
+ }
5930
+
5931
+ let defaultBeforeSnapshot;
5932
+ if (againstFlag !== undefined) {
5933
+ defaultBeforeSnapshot = loadSnapshot(targetAbs, againstFlag);
5934
+ if (!defaultBeforeSnapshot) {
5935
+ process.stderr.write(`agentic-security remediation reopen-check: no persisted GraphSnapshot found for commit "${againstFlag}" — pass a commit that was actually scanned.\n`);
5936
+ return 2;
5937
+ }
5938
+ } else {
5939
+ defaultBeforeSnapshot = mostRecentPriorSnapshot(targetAbs, afterSnapshot.commit);
5940
+ if (!defaultBeforeSnapshot) {
5941
+ process.stderr.write(`agentic-security remediation reopen-check: only one persisted GraphSnapshot exists (commit "${afterSnapshot.commit}") — nothing to compare against yet. Scan again after a code change, or pass --against <commit>.\n`);
5942
+ return 2;
5943
+ }
5944
+ }
5945
+
5946
+ const driftPolicyFlag = args.flags['drift-policy'];
5947
+ const driftPolicyProvided = driftPolicyFlag !== undefined;
5948
+ let policies = { policies: [] };
5949
+ if (driftPolicyProvided) {
5950
+ if (typeof driftPolicyFlag !== 'string' || !driftPolicyFlag) {
5951
+ process.stderr.write('agentic-security remediation reopen-check: --drift-policy requires a file path.\n');
5952
+ return 2;
5953
+ }
5954
+ const driftPolicyAbs = path.resolve(driftPolicyFlag);
5955
+ const check = _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, 'reopen-check');
5956
+ if (!check.ok) {
5957
+ process.stderr.write(check.message);
5958
+ return 2;
5959
+ }
5960
+ const { loadDriftPolicies } = await import('../src/lineage/drift-policy.js');
5961
+ policies = loadDriftPolicies(driftPolicyAbs);
5962
+ }
5963
+
5964
+ const { readLedgerEvents, appendLedgerEvent } = await import('../src/posture/remediation-ledger.js');
5965
+ const { foldRemediationLedger } = await import('../src/lineage/remediation.js');
5966
+ const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
5967
+ let evaluateDriftPolicies = null;
5968
+ if (driftPolicyProvided) {
5969
+ ({ evaluateDriftPolicies } = await import('../src/lineage/drift-policy.js'));
5970
+ }
5971
+
5972
+ const verifiedItems = Object.values(foldRemediationLedger(readLedgerEvents(targetAbs)))
5973
+ .filter((it) => it.state === 'verified')
5974
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
5975
+
5976
+ const findings = []; // {itemId, mechanism, reason, beforeSnapshotSource}
5977
+ const skipped = []; // {itemId, reason}
5978
+
5979
+ for (const item of verifiedItems) {
5980
+ let beforeSnapshot;
5981
+ let beforeSnapshotSource;
5982
+ if (item.verificationSnapshotId) {
5983
+ const found = snapshots.find((s) => s.id === item.verificationSnapshotId);
5984
+ if (found) { beforeSnapshot = found; beforeSnapshotSource = 'verification'; }
5985
+ }
5986
+ if (!beforeSnapshot) { beforeSnapshot = defaultBeforeSnapshot; beforeSnapshotSource = 'default'; }
5987
+
5988
+ if (!beforeSnapshot || beforeSnapshot.commit === afterSnapshot.commit) {
5989
+ skipped.push({ itemId: item.id, reason: 'no distinct before-snapshot could be resolved for this item' });
5990
+ continue;
5991
+ }
5992
+
5993
+ let diff;
5994
+ try {
5995
+ diff = computeGraphDiff(beforeSnapshot, afterSnapshot);
5996
+ } catch (e) {
5997
+ // Never a crash and never a silent pass — a diff-error item is
5998
+ // recorded as skipped, with its real reason.
5999
+ skipped.push({ itemId: item.id, reason: `incomparable snapshots: ${e && e.message ? e.message : e}` });
6000
+ continue;
6001
+ }
6002
+
6003
+ const affectedFlowIds = new Set(item.affectedFlowIds ?? []);
6004
+ let hit = null;
6005
+
6006
+ // Mechanism A: drift-policy regression.
6007
+ if (driftPolicyProvided) {
6008
+ const { violations } = evaluateDriftPolicies(diff, policies, afterSnapshot.graph);
6009
+ const violation = violations.find((v) => affectedFlowIds.has(v.flowId));
6010
+ if (violation) {
6011
+ hit = { mechanism: 'drift-policy', reason: `drift-policy rule (${violation.trigger}) matched flow ${violation.flowId}: ${violation.reason}` };
6012
+ }
6013
+ }
6014
+
6015
+ // Mechanism B: the control this item established went away — the
6016
+ // flagged flow REAPPEARED relative to the item's own baseline. This is
6017
+ // the symmetric complement of evaluateVerificationEvidence's own
6018
+ // "gone via diff.removed.flows" check: a regression is the flow coming
6019
+ // BACK, which shows up in diff.added.flows, never diff.removed/changed
6020
+ // (a flow disappearing is a FIX, never a regression — the original
6021
+ // code fired on exactly the wrong signal here, a real Blocking bug
6022
+ // found by the final review, live-reproduced against a successfully
6023
+ // remediated item). Also matches a re-identified reappearance (I6): a
6024
+ // flow whose own discriminator moved on the way back still counts, via
6025
+ // reidentifiedFrom.
6026
+ if (!hit) {
6027
+ const addedHit = (diff.added?.flows ?? []).find((e) =>
6028
+ affectedFlowIds.has(e.id) ||
6029
+ (e.causeClassification === 'reidentified' && affectedFlowIds.has(e.reidentifiedFrom))
6030
+ );
6031
+ if (addedHit) {
6032
+ hit = { mechanism: 'affected-flow-diff', reason: `affected flow reappeared as ${addedHit.id} in diff.added.flows (causeClassification: ${addedHit.causeClassification}${addedHit.reidentifiedFrom ? `, reidentified from ${addedHit.reidentifiedFrom}` : ''})` };
6033
+ }
6034
+ }
6035
+
6036
+ if (hit) {
6037
+ findings.push({ itemId: item.id, mechanism: hit.mechanism, reason: hit.reason, beforeSnapshotSource });
6038
+ }
6039
+ }
6040
+
6041
+ const yes = !!args.flags.yes;
6042
+
6043
+ if (!yes) {
6044
+ const report = {
6045
+ verb: 'reopen-check', afterSnapshotId: afterSnapshot.id, driftPolicyProvided,
6046
+ written: false, wouldReopen: findings, skipped,
6047
+ };
6048
+ _emitRemediationReport(report, args);
6049
+ return 0;
6050
+ }
6051
+
6052
+ if (findings.length > 0) {
6053
+ const { isSafeStateDir } = await import('../src/posture/state-dir.js');
6054
+ const { ledgerPaths } = await import('../src/posture/remediation-ledger.js');
6055
+ const { ledgerPath } = ledgerPaths(targetAbs);
6056
+ if (!isSafeStateDir(path.dirname(ledgerPath))) {
6057
+ process.stderr.write(`agentic-security remediation reopen-check: refusing to write — "${targetAbs}" does not look like a project directory.\n`);
6058
+ return 2;
6059
+ }
6060
+ }
6061
+
6062
+ const reopened = [];
6063
+ const failedToReopen = [];
6064
+ try {
6065
+ for (const f of findings) {
6066
+ const payload = { type: 'reopened', at: new Date().toISOString(), itemId: f.itemId, reason: f.reason };
6067
+ // Each event is independently validated by appendLedgerEvent's own
6068
+ // (sole-authoritative) validateTransition call — an item that raced
6069
+ // into a non-`verified` state between the fold above and this append
6070
+ // is correctly rejected here and reported as skipped, never
6071
+ // force-written.
6072
+ const result = await appendLedgerEvent(targetAbs, payload);
6073
+ if (result.valid) {
6074
+ reopened.push({ itemId: f.itemId, mechanism: f.mechanism, reason: f.reason, beforeSnapshotSource: f.beforeSnapshotSource, eventHash: result.hash });
6075
+ } else {
6076
+ failedToReopen.push({ itemId: f.itemId, errors: result.errors });
6077
+ }
6078
+ }
6079
+ } catch (e) {
6080
+ process.stderr.write(`agentic-security remediation reopen-check: unexpected error writing the ledger: ${e && e.message ? e.message : e}\n`);
6081
+ return 4;
6082
+ }
6083
+
6084
+ if (reopened.length > 0) {
6085
+ const { auditCall } = await import('../src/mcp/audit.js');
6086
+ auditCall({
6087
+ sessionRoot: targetAbs,
6088
+ tool: 'remediation_reopen_check',
6089
+ args: { itemIds: reopened.map((r) => r.itemId) },
6090
+ outcome: 'ok',
6091
+ });
6092
+ }
6093
+
6094
+ const report = {
6095
+ verb: 'reopen-check', afterSnapshotId: afterSnapshot.id, driftPolicyProvided,
6096
+ written: reopened.length > 0, reopened, failedToReopen, skipped,
6097
+ };
6098
+ _emitRemediationReport(report, args);
6099
+ return 0;
6100
+ }
6101
+
6102
+ // Terse [watch-dataflow] status line — mirrors watch-mode.js's own
6103
+ // renderStatusLine terse style, adapted to a GraphDiff's own
6104
+ // added/removed/changed shape (computeGraphDiff's `changed` bucket is
6105
+ // flows-only, per graph-diff.js's own header).
6106
+ function _renderDataflowWatchStatusLine(diff) {
6107
+ const n = diff.added.nodes.length, N = diff.removed.nodes.length;
6108
+ const e = diff.added.edges.length, E = diff.removed.edges.length;
6109
+ const d = diff.added.dataElements.length, D = diff.removed.dataElements.length;
6110
+ const f = diff.added.flows.length, F = diff.removed.flows.length, C = diff.changed.flows.length;
6111
+ if (n + N + e + E + d + D + f + F + C === 0) return 'no data-flow graph changes';
6112
+ return `+${f}/-${F} flows (~${C} changed), +${n}/-${N} nodes, +${e}/-${E} edges, +${d}/-${D} data elements`;
6113
+ }
6114
+
6115
+ // A louder, clearly-marked, Markdown-free plain-text block naming each
6116
+ // drift-policy violation — a model of _renderDataflowDiffMarkdown's own
6117
+ // violation-table CONTENT (trigger/flow/data-elements/sink/reason), never
6118
+ // its literal Markdown-table code (this command's whole output is a live
6119
+ // stderr stream, not a file). --fail-on-drift has no exit-code effect in a
6120
+ // long-running watch process (there is no single exit code to gate — see
6121
+ // this command's own startup banner / commands/dataflow.md) — it only
6122
+ // changes how loud this block reads.
6123
+ function _renderDataflowWatchViolations(violations, failOnDrift) {
6124
+ const marker = failOnDrift
6125
+ ? '[watch-dataflow] !!! DRIFT POLICY VIOLATION !!!'
6126
+ : '[watch-dataflow] *** drift policy violation ***';
6127
+ const lines = [];
6128
+ for (const v of violations) {
6129
+ lines.push(marker);
6130
+ lines.push(`[watch-dataflow] ${v.trigger}: flow ${v.flowId} — ${(v.dataElementNames ?? []).join(', ') || '(unclassified data)'} -> ${v.sinkCategory ?? v.sinkNodeId ?? 'unknown sink'}`);
6131
+ lines.push(`[watch-dataflow] ${v.reason}`);
6132
+ }
6133
+ return lines.length ? lines.join('\n') + '\n' : '';
6134
+ }
6135
+
6136
+ // #9 (M4 deliverable #9, watch-mode graph delta updates) — re-runs a deep
6137
+ // lineage scan on every debounced file-system change and reports the real
6138
+ // GraphDiff, reusing already-shipped Task 1 (buildGraphSnapshot)/8b
6139
+ // (computeGraphDiff/drift-policy) primitives. Mirrors cmdScan's own
6140
+ // `--watch` blocking/Ctrl-C/stderr-status-line UX (bin/agentic-security.js,
6141
+ // `args.flags['watch']`) — see that block's own header comment for the
6142
+ // shared "blocks until Ctrl-C" contract.
6143
+ //
6144
+ // Two disclosed, deliberate scope boundaries (docs/superpowers/plans/
6145
+ // 2026-09-02-data-flow-explorer-m4-watch-mode-scoping.md §2/§3), both named
6146
+ // in this command's own startup banner, not just here:
6147
+ // - NEVER calls persistGraphSnapshot — only buildGraphSnapshot (Task 1's
6148
+ // own pure, zero-disk-I/O builder). Every rescan before a real `git
6149
+ // commit` resolves to the same HEAD, so persisting on every debounced
6150
+ // rescan would silently overwrite real, commit-keyed snapshot history
6151
+ // with transient, mid-edit graph state. The "before" snapshot lives
6152
+ // purely in the closure variable `prevSnapshot` below, exactly like
6153
+ // the existing SAST `--watch`'s own `prevFindings` closure variable.
6154
+ // - Does NOT refresh .agentic-security/lineage-graph.json on any
6155
+ // rescan — an already-running `explore` session has no live-reload
6156
+ // (confirmed directly against src/server/*.js) and would not reflect
6157
+ // these edits regardless; disclosed rather than implied away.
6158
+ async function cmdDataflowWatch(args) {
6159
+ const target = args._[2] || '.';
6160
+ const targetAbs = path.resolve(target);
6161
+
6162
+ const driftPolicyFlag = args.flags['drift-policy'];
6163
+ if (driftPolicyFlag !== undefined && (typeof driftPolicyFlag !== 'string' || !driftPolicyFlag)) {
6164
+ process.stderr.write('agentic-security dataflow watch: --drift-policy requires a file path.\n');
6165
+ return 2;
6166
+ }
6167
+ const failOnDrift = !!args.flags['fail-on-drift'];
6168
+ const driftPolicyProvided = driftPolicyFlag !== undefined;
6169
+
6170
+ let policies = null;
6171
+ let evaluateDriftPolicies = null;
6172
+ if (driftPolicyProvided) {
6173
+ const driftPolicyAbs = path.resolve(driftPolicyFlag);
6174
+ const check = _validateDriftPolicyFile(driftPolicyAbs, driftPolicyFlag, 'watch');
6175
+ if (!check.ok) {
6176
+ process.stderr.write(check.message);
6177
+ return 2;
6178
+ }
6179
+ const driftPolicyModule = await import('../src/lineage/drift-policy.js');
6180
+ policies = driftPolicyModule.loadDriftPolicies(driftPolicyAbs);
6181
+ evaluateDriftPolicies = driftPolicyModule.evaluateDriftPolicies;
6182
+ }
6183
+
6184
+ const { watchProject } = await import('../src/posture/watch-mode.js');
6185
+ const { buildGraphSnapshot } = await import('../src/lineage/graph-snapshot.js');
6186
+ const { computeGraphDiff } = await import('../src/lineage/graph-diff.js');
6187
+
6188
+ // This is what makes runScan attach scan.lineageGraph at all — without
6189
+ // it, every rescan (including the seed) produces no graph and this
6190
+ // command has nothing to diff. Mirrors cmdScan's own `--watch` branch
6191
+ // setting AGENTIC_SECURITY_INCREMENTAL before its own seed scan.
6192
+ process.env.AGENTIC_SECURITY_LINEAGE_DEEP = '1';
6193
+
6194
+ // Deliberately printed BEFORE the (possibly multi-second) seed scan —
6195
+ // real-time feedback that the command is alive, not stuck — but this is
6196
+ // NOT the "the watcher is live" signal; see the banner printed below,
6197
+ // right before `watchProject` is called, for that.
6198
+ process.stderr.write(`[watch-dataflow] running seed scan for ${targetAbs}...\n`);
6199
+
6200
+ const _lineageStatusReason = (ls) => (ls && ls.failure) ? `failure: ${ls.failure}` : (ls && ls.reason) ? `reason: ${ls.reason}` : 'unknown reason';
6201
+
6202
+ // withStateWritesDisabled, for the same reason lsp/server.js's on-save scan
6203
+ // and mcp/tools.js's scan_diff (FR-704) wrap their own repeated runScan
6204
+ // calls. This command reruns a full scan on every debounced file edit for
6205
+ // as long as the watch session stays open, against the user's real project
6206
+ // root, with state writes fully enabled by default — without the wrapper,
6207
+ // every single-edit rescan writes ~11 real files into .agentic-security/
6208
+ // (dpia.md, ropa.md, threat-model.*, privacy-framework.*, data-inventory.json,
6209
+ // sbom-history/, ...), appends to scan-history.json, and — the genuinely
6210
+ // dangerous part — feeds posture/provenance/lifecycle.js's introduce/
6211
+ // remediate/reintroduce ledger. A real, transient mid-edit change (e.g. a
6212
+ // cut-and-paste during a refactor) would get recorded as a FABRICATED
6213
+ // `remediated` event (commit: null, authorDate: null), later undone by a
6214
+ // fabricated `reintroduced` event — permanently corrupting the one
6215
+ // artifact that backs finding provenance, MTTR reporting, and signed
6216
+ // evidence bundles, for a "fix" that never happened. exceptCategories:
6217
+ // ['provenance-cache'] mirrors lsp/server.js's own exact precedent: it
6218
+ // keeps ONLY the provenance disk cache live across repeated rescans in one
6219
+ // watch session (so a slowly-changing project doesn't pay the full
6220
+ // uncached provenance-resolution cost on every edit), while every OTHER
6221
+ // state write named above stays fully suppressed. Do not "simplify" this
6222
+ // back to a bare runScan call.
6223
+ const seed = await withStateWritesDisabled(() => runScan(targetAbs, {}), { exceptCategories: ['provenance-cache'] });
6224
+ if (!seed.scan.lineageGraph) {
6225
+ process.stderr.write(`agentic-security dataflow watch: seed scan produced no data-flow graph (${_lineageStatusReason(seed.scan.lineageStatus)}) — nothing to watch/diff against.\n`);
6226
+ return 1;
6227
+ }
6228
+ let prevSnapshot = buildGraphSnapshot(seed.scan.lineageGraph, targetAbs);
6229
+
6230
+ // watchProject itself checks process.env.AGENTIC_SECURITY_NO_WATCH === '1'
6231
+ // internally and returns immediately without ever subscribing to fs.watch
6232
+ // — so if we printed the "watching..." banner unconditionally below, a
6233
+ // caller with the env var set would see "watching..." immediately followed
6234
+ // by the process exiting, having watched nothing. Check the exact same
6235
+ // condition watchProject checks (do not invent a different one that could
6236
+ // drift from it) and print an honest message instead.
6237
+ if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') {
6238
+ process.stderr.write(`[watch-dataflow] AGENTIC_SECURITY_NO_WATCH=1 — watch mode is disabled; ran the seed scan only, not watching ${targetAbs} for changes.\n`);
6239
+ return 0;
6240
+ }
6241
+
6242
+ // Printed AFTER the seed scan succeeds, immediately before `watchProject`
6243
+ // subscribes — this is the real "the watcher is live" signal a caller
6244
+ // (this command's own CLI test included) can poll stderr for, unlike the
6245
+ // pre-seed-scan message above.
6246
+ process.stderr.write(`[watch-dataflow] watching ${targetAbs} for data-flow graph changes on file change — Ctrl-C to stop.\n`);
6247
+ process.stderr.write('[watch-dataflow] does NOT refresh .agentic-security/lineage-graph.json on any rescan — an already-running `agentic-security explore` session will not reflect these edits live.\n');
6248
+
6249
+ // No in-flight-rescan guard: if a debounced batch fires while a previous
6250
+ // rescan is still running, two concurrent invocations of this callback
6251
+ // can race on `prevSnapshot` (the closure variable below), producing a
6252
+ // diff against a stale baseline or clobbering it out of order on a large
6253
+ // enough project. Not fixed here — watchProject's own design has no
6254
+ // such guard, and the pre-existing `scan --watch` (this file's own
6255
+ // args.flags['watch'] branch) has the identical unguarded shape on its
6256
+ // own `prevFindings` variable; adding one here alone would diverge from
6257
+ // that established UX for no consistent reason. A future increment
6258
+ // could add a real mutex/generation-counter to BOTH commands together.
6259
+ await watchProject(targetAbs, async (batch, watchErr) => {
6260
+ if (watchErr) {
6261
+ process.stderr.write(`[watch-dataflow] file watcher error: ${watchErr && watchErr.message ? watchErr.message : watchErr} — no longer watching for changes.\n`);
6262
+ return;
6263
+ }
6264
+ try {
6265
+ const { scan } = await withStateWritesDisabled(() => runScan(targetAbs, {}), { exceptCategories: ['provenance-cache'] });
6266
+ if (!scan.lineageGraph) {
6267
+ process.stderr.write(`[watch-dataflow] rescan produced no data-flow graph (${_lineageStatusReason(scan.lineageStatus)}) — skipping this change.\n`);
6268
+ return;
6269
+ }
6270
+ const currSnapshot = buildGraphSnapshot(scan.lineageGraph, targetAbs);
6271
+
6272
+ let diff;
6273
+ try {
6274
+ diff = computeGraphDiff(prevSnapshot, currSnapshot);
6275
+ } catch (e) {
6276
+ process.stderr.write(`[watch-dataflow] could not diff: ${e && e.message ? e.message : e}\n`);
6277
+ return;
6278
+ }
6279
+
6280
+ let violations = [];
6281
+ if (driftPolicyProvided) {
6282
+ violations = evaluateDriftPolicies(diff, policies, currSnapshot.graph).violations;
6283
+ }
6284
+
6285
+ process.stderr.write('[watch-dataflow] ' + _renderDataflowWatchStatusLine(diff) + '\n');
6286
+ if (violations.length) {
6287
+ process.stderr.write(_renderDataflowWatchViolations(violations, failOnDrift));
6288
+ }
6289
+
6290
+ prevSnapshot = currSnapshot;
6291
+ } catch (e) {
6292
+ process.stderr.write(`[watch-dataflow] rescan failed: ${e && e.message ? e.message : e}\n`);
6293
+ }
6294
+ });
6295
+
6296
+ return 0; // watchProject itself does not block (see the dispatch site's
6297
+ // own comment, case 'dataflow' -> the `watch` branch, for the full
6298
+ // explanation) — the process stays alive on the live fs.watch handle
6299
+ // watchProject subscribes internally, exiting only via Ctrl-C/SIGTERM.
6300
+ // Do NOT "simplify" the dispatch call around this function back to
6301
+ // `process.exit(await cmdDataflowWatch(args))` — that is the exact
6302
+ // change that makes `scan --watch` exit before it ever watches anything.
6303
+ }
6304
+
6305
+ async function main() {
6306
+ const args = parseArgs(process.argv.slice(2));
6307
+ const cmd = args._[0];
6308
+ try {
6309
+ switch (cmd) {
6310
+ case 'scan': process.exit(await cmdScan(args));
6311
+ case 'ship': process.exit(await cmdShip(args));
6312
+ case 'ci': process.exit(await cmdCi(args));
6313
+ case 'fix': process.exit(await cmdFix(args));
6314
+ case 'undo': process.exit(await cmdUndo(args));
6315
+ case 'accept': process.exit(await cmdAccept(args));
6316
+ case 'profile': process.exit(await cmdProfile(args));
6317
+ case 'triage': process.exit(await cmdTriage(args));
6318
+ case 'org-scan': process.exit(await cmdOrgScan(args));
6319
+ case 'rules': process.exit(await cmdRules(args));
6320
+ case 'rule': process.exit(await cmdRule(args));
6321
+ case 'tickets': process.exit(await cmdTickets(args));
6322
+ case 'secure': process.exit(await cmdSecure(args));
6323
+ case 'packs': process.exit(await cmdPacks(args));
6324
+ case 'validator-cache': process.exit(await cmdValidatorCache(args));
6325
+ case 'verify': process.exit(await cmdVerify(args));
6326
+ case 'reset': process.exit(await cmdReset(args));
6327
+ case 'export': process.exit(await cmdExport(args));
6328
+ case 'legal-hold': process.exit(await cmdLegalHold(args));
6329
+ case 'calibration-feedback': process.exit(await cmdCalibrationFeedback(args));
6330
+ case 'calibration-report': process.exit(await cmdCalibrationReport(args));
6331
+ case 'hunt': process.exit(await cmdHunt(args));
6332
+ case 'compliance': process.exit(await cmdCompliance(args));
6333
+ case 'attest': process.exit(await cmdAttest(args));
6334
+ case 'verify-attestation': process.exit(await cmdVerifyAttestation(args));
6335
+ case 'policy-explain': process.exit(await cmdPolicyExplain(args));
6336
+ case 'policy-sign': process.exit(await cmdPolicySign(args));
6337
+ case 'rule-synth': process.exit(await cmdRuleSynth(args));
6338
+ case 'digest': process.exit(await cmdDigest(args));
6339
+ case 'setup': process.exit(await cmdSetup(args));
6340
+ case 'cache-report': {
6341
+ // Prompt-cache economics for the current session: parse the Claude Code
6342
+ // transcript usage and report cache-hit %, $ saved, and avoidable leaks.
6343
+ // Advisory/read-only — always exits 0.
6344
+ const { analyzeTranscript, formatCacheReport } = await import('../src/posture/cache-economics.js');
6345
+ const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
6346
+ const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
6347
+ if (args.flags.json) writeStdout(JSON.stringify(result, null, 2) + '\n');
6348
+ else console.log(formatCacheReport(result));
6349
+ process.exit(0);
6350
+ }
6351
+ case 'cache-statusline': {
6352
+ // F6 — one-line cost HUD for a Claude Code statusLine command. Also writes
6353
+ // .agentic-security/cache-telemetry.json for other pollers. Always exits 0.
6354
+ const { analyzeTranscript, renderCacheStatusLine } = await import('../src/posture/cache-economics.js');
6355
+ const projectDir = path.resolve(args.flags.root || process.env.CLAUDE_PROJECT_DIR || process.cwd());
6356
+ const result = analyzeTranscript({ transcriptPath: args.flags.transcript, projectDir });
6357
+ if (result.ok) {
6358
+ try {
6359
+ const dir = stateDir(projectDir);
6360
+ fs.mkdirSync(dir, { recursive: true });
6361
+ fs.writeFileSync(path.join(dir, 'cache-telemetry.json'),
6362
+ JSON.stringify({ updatedAt: new Date().toISOString(), metrics: result.metrics, leaks: result.leaks }, null, 2));
6363
+ } catch { /* best-effort */ }
6364
+ console.log(renderCacheStatusLine(result.metrics));
6365
+ } else {
6366
+ console.log('agentic-security: no session cost yet');
6367
+ }
6368
+ process.exit(0);
6369
+ }
6370
+ case 'mcp': {
6371
+ const { runStdio } = await import('../src/mcp/stdio.js');
6372
+ const root = args.flags.root || process.env.AGENTIC_SECURITY_MCP_ROOT || process.cwd();
6373
+ runStdio({ sessionRoot: path.resolve(root) });
6374
+ return;
6375
+ }
6376
+ case 'explore': process.exit(await cmdExplore(args));
6377
+ case 'dataflow': {
6378
+ const sub = args._[1];
6379
+ if (sub === 'export') { process.exit(await cmdDataflowExport(args)); }
6380
+ else if (sub === 'diff') { process.exit(await cmdDataflowDiff(args)); }
6381
+ else if (sub === 'watch') {
6382
+ // NOT `process.exit(await cmdDataflowWatch(args))` — deliberately.
6383
+ // process.exit() terminates immediately, ignoring any pending
6384
+ // handles/timers; cmdDataflowWatch's own success path (return 0)
6385
+ // resolves almost immediately once watchProject's internal
6386
+ // subscription is set up (watchProject itself doesn't block —
6387
+ // see that function's own header), so wrapping it in
6388
+ // process.exit() here would tear the process down before the
6389
+ // live fs.watch async iterator inside watchProject ever gets a
6390
+ // chance to fire — confirmed empirically (measured exit in
6391
+ // <1s, no rescan ever observed) against the pre-existing SAST
6392
+ // `scan --watch` (bin/agentic-security.js's `args.flags['watch']`
6393
+ // branch), which has this exact same latent bug via its own
6394
+ // identical `process.exit(await cmdScan(args))` dispatch — out
6395
+ // of scope to fix here (watch-mode.js/scan --watch are
6396
+ // deliberately unmodified by this sub-project), but the fix for
6397
+ // THIS new subcommand is to only exit explicitly on a non-zero
6398
+ // (validation-failure / no-lineage-graph) return, which happens
6399
+ // before watchProject is ever called and has nothing keeping
6400
+ // the process alive on its own. On the success path the process
6401
+ // is left to Node's default "still has an active handle" event
6402
+ // loop keep-alive, exiting only via Ctrl-C (SIGINT)'s own
6403
+ // Node-default termination.
6404
+ const code = await cmdDataflowWatch(args);
6405
+ if (code !== 0) process.exit(code);
6406
+ break;
6407
+ }
6408
+ else if (sub === 'scenario') {
6409
+ const scenarioSub = args._[2];
6410
+ if (scenarioSub === 'apply') { process.exit(await cmdDataflowScenarioApply(args)); }
6411
+ else {
6412
+ process.stderr.write(`agentic-security dataflow scenario: unrecognized sub-command "${scenarioSub}" — must be "apply".\n`);
6413
+ process.exit(2);
6414
+ }
6415
+ break;
6416
+ }
6417
+ else if (sub === 'impact') {
6418
+ const impactSub = args._[2];
6419
+ if (impactSub === 'assess') { process.exit(await cmdDataflowImpactAssess(args)); }
6420
+ else {
6421
+ process.stderr.write(`agentic-security dataflow impact: unrecognized sub-command "${impactSub}" — must be "assess".\n`);
6422
+ process.exit(2);
6423
+ }
6424
+ break;
6425
+ }
6426
+ else if (sub === 'observations') {
6427
+ const obsSub = args._[2];
6428
+ if (obsSub === 'import') { process.exit(await cmdDataflowObservationsImport(args)); }
6429
+ else if (obsSub === 'list') { process.exit(await cmdDataflowObservationsList(args)); }
6430
+ else {
6431
+ process.stderr.write(`agentic-security dataflow observations: unrecognized sub-command "${obsSub}" — must be "import" or "list".\n`);
6432
+ process.exit(2);
6433
+ }
6434
+ break;
6435
+ }
6436
+ else if (sub === 'twin') { process.exit(await cmdDataflowTwin(args)); }
6437
+ process.stderr.write(`agentic-security dataflow: unknown subcommand "${sub}" — only "export", "diff", "watch", "scenario", "impact", "observations", and "twin" are supported.\n`);
6438
+ process.exit(2);
6439
+ }
6440
+ case 'governance': {
6441
+ // NOT a `dataflow` subcommand — this edits operator config
6442
+ // (recipient-profiles.json), never the scanned graph. See
6443
+ // cmdGovernancePropose's own header comment for the full
6444
+ // exit-code/backup/audit contract.
6445
+ const sub = args._[1];
6446
+ if (sub === 'propose-edit') { process.exit(await cmdGovernancePropose(args)); }
6447
+ else {
6448
+ process.stderr.write(`agentic-security governance: unrecognized sub-command "${sub}" — must be "propose-edit".\n`);
6449
+ process.exit(2);
6450
+ }
6451
+ break;
6452
+ }
6453
+ case 'federate': {
6454
+ // NOT a `dataflow` subcommand — this writes operator-declared
6455
+ // config (cross-repo-links.json), never the scanned graph. Same
6456
+ // distinction that made `governance`/`remediation` their own
6457
+ // dispatchers. See cmdFederateDeclare's own header comment for
6458
+ // the full exit-code/backup/audit contract.
6459
+ const sub = args._[1];
6460
+ if (sub === 'declare') { process.exit(await cmdFederateDeclare(args)); }
6461
+ else if (sub === 'list') { process.exit(await cmdFederateList(args)); }
6462
+ else {
6463
+ process.stderr.write(`agentic-security federate: unrecognized sub-command "${sub}" — must be "declare" or "list".\n`);
6464
+ process.exit(2);
6465
+ }
6466
+ break;
6467
+ }
6468
+ case 'remediation': {
6469
+ // NOT a `dataflow` subcommand — this writes operator/incident
6470
+ // state (the append-only remediation ledger), never the scanned
6471
+ // graph. Same distinction that made `governance` its own
6472
+ // dispatcher. See _remediationWrite's own header for the full
6473
+ // base-event/isSafeStateDir/audit/exit-code contract.
6474
+ const sub = args._[1];
6475
+ if (sub === 'open') { process.exit(await cmdRemediationOpen(args)); }
6476
+ else if (sub === 'update') { process.exit(await cmdRemediationUpdate(args)); }
6477
+ else if (sub === 'verify') { process.exit(await cmdRemediationVerify(args)); }
6478
+ else if (sub === 'accept-risk') { process.exit(await cmdRemediationAcceptRisk(args)); }
6479
+ else if (sub === 'reopen-check') { process.exit(await cmdRemediationReopenCheck(args)); }
6480
+ else if (sub === 'list') { process.exit(await cmdRemediationList(args)); }
6481
+ else {
6482
+ process.stderr.write(`agentic-security remediation: unrecognized sub-command "${sub}" — must be one of open|update|verify|accept-risk|reopen-check|list.\n`);
6483
+ process.exit(2);
6484
+ }
6485
+ break;
3047
6486
  }
3048
6487
  case 'cve-watch': {
3049
6488
  // Continuous CVE-watch daemon (one-shot). Polls OSV for the project's