@clear-capabilities/agentic-security-scanner 0.184.0 → 0.186.1

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 (228) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/agentic-security.js +111 -1
  3. package/dist/1128.index.js +912 -0
  4. package/dist/1665.index.js +828 -0
  5. package/dist/2584.index.js +1 -1
  6. package/dist/4171.index.js +2 -2
  7. package/dist/5752.index.js +466 -0
  8. package/dist/7405.index.js +1 -1
  9. package/dist/8158.index.js +1713 -0
  10. package/dist/8798.index.js +1 -1
  11. package/dist/9382.index.js +2 -2
  12. package/dist/9579.index.js +1 -1
  13. package/dist/agentic-security.mjs +29 -29
  14. package/dist/agentic-security.mjs.sha256 +1 -1
  15. package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
  16. package/dist/frontend/src/shell.js +12 -2
  17. package/package.json +29 -12
  18. package/src/dataflow/CLAUDE.md +46 -1
  19. package/src/dataflow/access-paths.js +12 -2
  20. package/src/dataflow/backward.js +42 -7
  21. package/src/dataflow/cross-service-taint.js +83 -1
  22. package/src/dataflow/demand-query.js +1168 -0
  23. package/src/dataflow/dep-fingerprint.js +718 -0
  24. package/src/dataflow/engine.js +48 -6
  25. package/src/dataflow/exploit-prover.js +6 -2
  26. package/src/dataflow/ifds-precise.js +22 -0
  27. package/src/dataflow/incremental-replay.js +118 -0
  28. package/src/dataflow/incremental-session.js +257 -0
  29. package/src/dataflow/incremental.js +237 -35
  30. package/src/dataflow/index.js +25 -94
  31. package/src/dataflow/ir-cache.js +268 -0
  32. package/src/dataflow/privacy-taint.js +7 -0
  33. package/src/dataflow/summaries.js +72 -6
  34. package/src/dataflow/tabulation.js +80 -24
  35. package/src/engine.js +113 -8
  36. package/src/graph/CLAUDE.md +31 -0
  37. package/src/graph/adapters/artifact.js +81 -0
  38. package/src/graph/adapters/crossrepo.js +75 -0
  39. package/src/graph/adapters/dataflow.js +76 -0
  40. package/src/graph/adapters/provenance.js +92 -0
  41. package/src/graph/adapters/semantic.js +89 -0
  42. package/src/graph/crosswalk-builders.js +152 -0
  43. package/src/graph/crosswalk.js +146 -0
  44. package/src/graph/index.js +15 -0
  45. package/src/graph/observation.js +57 -0
  46. package/src/graph/refs.js +105 -0
  47. package/src/graph/registry.js +129 -0
  48. package/src/graph/relations.js +81 -0
  49. package/src/graph/scc.js +241 -0
  50. package/src/graph/view.js +254 -0
  51. package/src/ir/CLAUDE.md +4 -0
  52. package/src/ir/callgraph.js +36 -8
  53. package/src/ir/index.js +18 -4
  54. package/src/ir/parser-js.js +10 -0
  55. package/src/language/CLAUDE.md +13 -0
  56. package/src/language/nix-option-types.js +170 -0
  57. package/src/language/nix-script-taint.js +46 -3
  58. package/src/language/nix-secrets.js +20 -2
  59. package/src/lineage/CLAUDE.md +72 -8
  60. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  61. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  62. package/src/lineage/boundary-contract.js +273 -0
  63. package/src/lineage/completion.js +205 -0
  64. package/src/lineage/continuation.js +140 -0
  65. package/src/lineage/control-credit.js +313 -0
  66. package/src/lineage/correlation.js +199 -0
  67. package/src/lineage/coverage.js +8 -1
  68. package/src/lineage/cross-repo-link.js +22 -0
  69. package/src/lineage/cut-planner.js +548 -0
  70. package/src/lineage/driver.js +219 -66
  71. package/src/lineage/engine.js +166 -21
  72. package/src/lineage/export-json.js +5 -0
  73. package/src/lineage/graph-builder.js +84 -7
  74. package/src/lineage/haskell-view.js +59 -0
  75. package/src/lineage/index.js +3 -0
  76. package/src/lineage/nix-view.js +35 -0
  77. package/src/lineage/owned-build.js +77 -0
  78. package/src/lineage/owned-worker-entry.js +22 -0
  79. package/src/lineage/owned-worker.js +95 -0
  80. package/src/lineage/path-evidence.js +123 -0
  81. package/src/lineage/path-predicates.js +529 -0
  82. package/src/lineage/path-query.js +189 -20
  83. package/src/lineage/path-store.js +191 -17
  84. package/src/lineage/scc-solver.js +186 -0
  85. package/src/lineage/smt-orchestrator.js +174 -0
  86. package/src/lineage/smt-z3-backend.js +294 -0
  87. package/src/lineage/source-seeding.js +36 -2
  88. package/src/lineage/summaries.js +66 -4
  89. package/src/lineage/value-versions.js +130 -0
  90. package/src/lineage/witness-selection.js +95 -0
  91. package/src/pipeline/scan-health.js +18 -0
  92. package/src/posture/CLAUDE.md +17 -0
  93. package/src/posture/business-logic.js +16 -7
  94. package/src/posture/dag-commands.js +442 -0
  95. package/src/posture/evidence-bundle.js +1 -1
  96. package/src/posture/finding-defaults.js +19 -0
  97. package/src/posture/git-history.js +91 -1
  98. package/src/posture/witness-bundle.js +573 -0
  99. package/src/posture/witness-runtime.js +276 -0
  100. package/src/report/index.js +2 -0
  101. package/src/sast/CLAUDE.md +2 -0
  102. package/src/sast/_call-args.js +70 -0
  103. package/src/sast/init-scope.js +87 -0
  104. package/src/sast/listen-bind-address.js +49 -0
  105. package/src/sast/redirect-target.js +106 -0
  106. package/src/sast/reflected-input.js +76 -0
  107. package/src/sast/send-file-root.js +27 -0
  108. package/src/sast/sensitive-data-logging.js +38 -1
  109. package/dist/119.index.js +0 -197
  110. package/dist/128.index.js +0 -129
  111. package/dist/1293.index.js +0 -5689
  112. package/dist/1336.index.js +0 -16182
  113. package/dist/1351.index.js +0 -133
  114. package/dist/1387.index.js +0 -271
  115. package/dist/1437.index.js +0 -23
  116. package/dist/1445.index.js +0 -110
  117. package/dist/1505.index.js +0 -151
  118. package/dist/1557.index.js +0 -293
  119. package/dist/1621.index.js +0 -311
  120. package/dist/1751.index.js +0 -160
  121. package/dist/1765.index.js +0 -727
  122. package/dist/1861.index.js +0 -690
  123. package/dist/1906.index.js +0 -433
  124. package/dist/208.index.js +0 -1631
  125. package/dist/2223.index.js +0 -159
  126. package/dist/2238.index.js +0 -295
  127. package/dist/2530.index.js +0 -152
  128. package/dist/2565.index.js +0 -116
  129. package/dist/2574.index.js +0 -127
  130. package/dist/2620.index.js +0 -345
  131. package/dist/2773.index.js +0 -98
  132. package/dist/2775.index.js +0 -32
  133. package/dist/2966.index.js +0 -171
  134. package/dist/2972.index.js +0 -87
  135. package/dist/2991.index.js +0 -131
  136. package/dist/3063.index.js +0 -833
  137. package/dist/3075.index.js +0 -227
  138. package/dist/3121.index.js +0 -297
  139. package/dist/3314.index.js +0 -263
  140. package/dist/3480.index.js +0 -1822
  141. package/dist/3481.index.js +0 -24
  142. package/dist/3506.index.js +0 -268
  143. package/dist/3533.index.js +0 -591
  144. package/dist/3653.index.js +0 -450
  145. package/dist/3740.index.js +0 -722
  146. package/dist/3822.index.js +0 -131
  147. package/dist/3868.index.js +0 -292
  148. package/dist/3918.index.js +0 -192
  149. package/dist/4090.index.js +0 -288
  150. package/dist/4113.index.js +0 -1286
  151. package/dist/4121.index.js +0 -1190
  152. package/dist/4142.index.js +0 -690
  153. package/dist/4168.index.js +0 -509
  154. package/dist/4281.index.js +0 -993
  155. package/dist/4336.index.js +0 -436
  156. package/dist/4387.index.js +0 -532
  157. package/dist/4413.index.js +0 -302
  158. package/dist/4476.index.js +0 -4510
  159. package/dist/4491.index.js +0 -133
  160. package/dist/4538.index.js +0 -185
  161. package/dist/4684.index.js +0 -319
  162. package/dist/4740.index.js +0 -165
  163. package/dist/4780.index.js +0 -209
  164. package/dist/4802.index.js +0 -225
  165. package/dist/4812.index.js +0 -379
  166. package/dist/4835.index.js +0 -126
  167. package/dist/494.index.js +0 -226
  168. package/dist/4991.index.js +0 -549
  169. package/dist/5199.index.js +0 -305
  170. package/dist/5216.index.js +0 -109
  171. package/dist/5439.index.js +0 -301
  172. package/dist/5443.index.js +0 -97
  173. package/dist/5496.index.js +0 -377
  174. package/dist/5738.index.js +0 -240
  175. package/dist/5801.index.js +0 -588
  176. package/dist/5817.index.js +0 -714
  177. package/dist/5858.index.js +0 -223
  178. package/dist/5933.index.js +0 -143
  179. package/dist/601.index.js +0 -23
  180. package/dist/6173.index.js +0 -273
  181. package/dist/6291.index.js +0 -1871
  182. package/dist/6312.index.js +0 -666
  183. package/dist/634.index.js +0 -16182
  184. package/dist/644.index.js +0 -87
  185. package/dist/6508.index.js +0 -283
  186. package/dist/6560.index.js +0 -645
  187. package/dist/6576.index.js +0 -422
  188. package/dist/6579.index.js +0 -271
  189. package/dist/6655.index.js +0 -3221
  190. package/dist/6745.index.js +0 -305
  191. package/dist/6818.index.js +0 -21642
  192. package/dist/6932.index.js +0 -1190
  193. package/dist/6972.index.js +0 -167
  194. package/dist/6995.index.js +0 -163
  195. package/dist/7249.index.js +0 -191
  196. package/dist/7372.index.js +0 -1197
  197. package/dist/7546.index.js +0 -541
  198. package/dist/76.index.js +0 -448
  199. package/dist/7879.index.js +0 -163
  200. package/dist/8025.index.js +0 -130
  201. package/dist/8054.index.js +0 -248
  202. package/dist/808.index.js +0 -1330
  203. package/dist/8097.index.js +0 -201
  204. package/dist/8126.index.js +0 -191
  205. package/dist/8178.index.js +0 -78
  206. package/dist/8382.index.js +0 -93
  207. package/dist/8408.index.js +0 -261
  208. package/dist/8540.index.js +0 -174
  209. package/dist/8635.index.js +0 -802
  210. package/dist/8688.index.js +0 -410
  211. package/dist/8699.index.js +0 -779
  212. package/dist/8891.index.js +0 -457
  213. package/dist/8916.index.js +0 -885
  214. package/dist/9146.index.js +0 -170
  215. package/dist/9168.index.js +0 -841
  216. package/dist/9292.index.js +0 -369
  217. package/dist/9422.index.js +0 -866
  218. package/dist/9440.index.js +0 -453
  219. package/dist/9479.index.js +0 -152
  220. package/dist/9481.index.js +0 -220
  221. package/dist/9539.index.js +0 -100
  222. package/dist/9547.index.js +0 -103
  223. package/dist/9692.index.js +0 -90
  224. package/dist/9800.index.js +0 -462
  225. package/dist/9899.index.js +0 -138
  226. package/dist/9981.index.js +0 -206
  227. package/dist/999.index.js +0 -117
  228. package/dist/9999.index.js +0 -1698
@@ -0,0 +1,548 @@
1
+ // cut-planner.js — DAG-09: graph cuts and control-placement planning.
2
+ //
3
+ // Pure module. Given a DataFlowGraph v1 and a set of CANDIDATE control
4
+ // locations (each with semantics, owner, estimated cost and feasibility),
5
+ // it builds a query-specific source-to-sink flow network, computes a
6
+ // minimum-cost cut with iterative Dinic max-flow (no recursion; cycles are
7
+ // fine), and validates the proposed set by applying it as a hypothetical
8
+ // scenario through scenario-engine.js's `applyScenario` and searching the
9
+ // RESULT graph for remaining bypasses.
10
+ //
11
+ // What this module is and is not:
12
+ // - Every placement it returns is HYPOTHETICAL. Nothing here reads or
13
+ // writes remediation state, and this module deliberately does not
14
+ // import remediation.js: only a real rescan feeding the existing
15
+ // verification gate (scan_verification / manual_attestation) can ever
16
+ // move a remediation item to `verified`. Results carry `verified:false`.
17
+ // - The cut is exact only for the finite, fixed-capacity network it
18
+ // builds from the MODELED edge set. Multi-query shared-control planning
19
+ // (`planSharedControls`) is a greedy weighted set cover and is labelled
20
+ // approximate; it makes no optimality claim.
21
+ // - A cut certificate is derived from the full modeled edge set
22
+ // (graph.edges), never from a capped display-path list. When the graph
23
+ // is not known to be complete (coverage.analysisCompletion absent or
24
+ // not complete, or non-modeled nodes inside the network) the result is
25
+ // scoped to the modeled subgraph and `totalCoverage` is 'unknown'.
26
+ // - Controls are modelled only as the scenario engine can express them:
27
+ // transit protection or handling on an edge, or removal of an entity.
28
+ // A node control is vertex-split: it guards every path through the node.
29
+
30
+ import { applyScenario } from './scenario-engine.js';
31
+
32
+ /** Capacity standing in for "no feasible control here" (cannot be cut). */
33
+ export const UNCUTTABLE = 1e12;
34
+ const MAX_COST = 1e6;
35
+ const EPS = 1e-9;
36
+
37
+ export const CONTROL_SEMANTICS = Object.freeze([
38
+ 'transit_protection', 'handling', 'remove_entity',
39
+ ]);
40
+
41
+ // ── Max-flow / min-cut (Dinic, iterative) ─────────────────────────────────
42
+
43
+ /**
44
+ * Max-flow on `n` vertices (0..n-1) with directed `arcs` of
45
+ * `{from, to, cap}`. Returns `{maxFlow, reachable, cutArcs}` where
46
+ * `cutArcs` are indices into `arcs` crossing the source-side residual
47
+ * reachable set (a minimum cut). Iterative; handles cycles and parallel arcs.
48
+ */
49
+ export function maxFlowMinCut(n, arcs, s, t) {
50
+ const head = new Array(n).fill(-1);
51
+ const to = []; const cap = []; const next = [];
52
+ const arcEdge = [];
53
+ const addEdge = (u, v, c) => {
54
+ to.push(v); cap.push(c); next.push(head[u]); head[u] = to.length - 1;
55
+ to.push(u); cap.push(0); next.push(head[v]); head[v] = to.length - 1;
56
+ };
57
+ arcs.forEach((a, i) => { arcEdge[i] = to.length; addEdge(a.from, a.to, a.cap); });
58
+
59
+ let flow = 0;
60
+ if (s === t) return { maxFlow: 0, reachable: new Array(n).fill(false), cutArcs: [] };
61
+ const level = new Array(n);
62
+ for (;;) {
63
+ level.fill(-1);
64
+ level[s] = 0;
65
+ const queue = [s];
66
+ for (let qi = 0; qi < queue.length; qi++) {
67
+ const u = queue[qi];
68
+ for (let e = head[u]; e !== -1; e = next[e]) {
69
+ if (cap[e] > EPS && level[to[e]] < 0) { level[to[e]] = level[u] + 1; queue.push(to[e]); }
70
+ }
71
+ }
72
+ if (level[t] < 0) break;
73
+ const it = head.slice();
74
+ const stack = [];
75
+ let u = s;
76
+ blocking: for (;;) {
77
+ while (u !== t) {
78
+ let advanced = false;
79
+ for (; it[u] !== -1; it[u] = next[it[u]]) {
80
+ const e = it[u];
81
+ if (cap[e] > EPS && level[to[e]] === level[u] + 1) { stack.push(e); u = to[e]; advanced = true; break; }
82
+ }
83
+ if (!advanced) {
84
+ if (u === s) break blocking;
85
+ level[u] = -1; // dead end
86
+ const e = stack.pop();
87
+ u = to[e ^ 1];
88
+ it[u] = next[it[u]];
89
+ }
90
+ }
91
+ let b = Infinity;
92
+ for (const e of stack) if (cap[e] < b) b = cap[e];
93
+ for (const e of stack) { cap[e] -= b; cap[e ^ 1] += b; }
94
+ flow += b;
95
+ stack.length = 0;
96
+ u = s;
97
+ }
98
+ }
99
+ const reachable = new Array(n).fill(false);
100
+ reachable[s] = true;
101
+ const q = [s];
102
+ for (let qi = 0; qi < q.length; qi++) {
103
+ const u = q[qi];
104
+ for (let e = head[u]; e !== -1; e = next[e]) {
105
+ if (cap[e] > EPS && !reachable[to[e]]) { reachable[to[e]] = true; q.push(to[e]); }
106
+ }
107
+ }
108
+ const cutArcs = [];
109
+ arcs.forEach((a, i) => {
110
+ if (a.cap > 0 && reachable[a.from] && !reachable[a.to]) cutArcs.push(i);
111
+ });
112
+ return { maxFlow: flow, reachable, cutArcs };
113
+ }
114
+
115
+ // ── Candidates ────────────────────────────────────────────────────────────
116
+
117
+ function _locKey(loc) { return `${loc.type}:${loc.id}`; }
118
+
119
+ /**
120
+ * Normalizes and screens candidate interventions. A candidate:
121
+ * { id?, location: {type:'edge'|'node', id}, semantics, owner, cost,
122
+ * feasible?, disallowed?, families?, handling? }
123
+ * Returns {eligible: Map<locKey, candidate>, excluded: [{candidate, reason}]}.
124
+ * Eligible = valid shape, feasible, not disallowed, family-compatible.
125
+ */
126
+ export function screenCandidates(candidates, query = {}) {
127
+ const excluded = [];
128
+ const best = new Map();
129
+ for (const raw of candidates ?? []) {
130
+ const c = raw && typeof raw === 'object' ? raw : {};
131
+ const loc = c.location;
132
+ const reject = (reason) => excluded.push({ candidate: raw, reason });
133
+ if (!loc || (loc.type !== 'edge' && loc.type !== 'node') || typeof loc.id !== 'string' || !loc.id) {
134
+ reject('location must be {type: edge|node, id}'); continue;
135
+ }
136
+ if (!CONTROL_SEMANTICS.includes(c.semantics)) { reject(`semantics must be one of ${CONTROL_SEMANTICS.join('|')}`); continue; }
137
+ if (c.semantics === 'remove_entity' && loc.type !== 'node') { reject('remove_entity applies to a node location'); continue; }
138
+ if (typeof c.owner !== 'string' || !c.owner) { reject('owner is required'); continue; }
139
+ if (typeof c.cost !== 'number' || !Number.isFinite(c.cost) || c.cost <= 0 || c.cost > MAX_COST) {
140
+ reject(`cost must be a finite number in (0, ${MAX_COST}]`); continue;
141
+ }
142
+ if (c.disallowed === true) { reject('intervention site is disallowed'); continue; }
143
+ if (c.feasible === false) { reject('intervention is marked infeasible'); continue; }
144
+ if (query.family && Array.isArray(c.families) && c.families.length > 0 && !c.families.includes(query.family)) {
145
+ reject(`control does not support rule family "${query.family}"`); continue;
146
+ }
147
+ const cand = { ...c, id: c.id ?? `cand:${_locKey(loc)}:${c.semantics}` };
148
+ const key = _locKey(loc);
149
+ const cur = best.get(key);
150
+ if (!cur || cand.cost < cur.cost || (cand.cost === cur.cost && cand.id < cur.id)) best.set(key, cand);
151
+ }
152
+ return { eligible: best, excluded };
153
+ }
154
+
155
+ // ── Network construction ──────────────────────────────────────────────────
156
+
157
+ function _edgePreProtected(e) {
158
+ const p = e.protection;
159
+ if (!p) return false;
160
+ return ['transit', 'atRest', 'handling'].some((d) => p[d]?.verdict === 'protected');
161
+ }
162
+
163
+ function _assessCompleteness(graph, usedNodeIds) {
164
+ const reasons = [];
165
+ const ac = graph.coverage?.analysisCompletion ?? graph.scanHealth?.analysisCompletion;
166
+ if (ac === undefined || ac === null) reasons.push('graph carries no analysisCompletion field, so completeness is unknown');
167
+ else if (!(ac === 'complete' || ac === 'full' || ac === true || ac === 1)) reasons.push(`analysisCompletion is ${JSON.stringify(ac)}`);
168
+ const unresolved = graph.coverage?.unresolvedDestinations;
169
+ if (typeof unresolved === 'number' && unresolved > 0) reasons.push(`${unresolved} unresolved destination(s)`);
170
+ let nonModeled = 0;
171
+ for (const n of graph.nodes ?? []) {
172
+ if (usedNodeIds.has(n.id) && n.coverageStatus !== undefined && n.coverageStatus !== 'modeled') nonModeled++;
173
+ }
174
+ if (nonModeled > 0) reasons.push(`${nonModeled} network node(s) are not fully modeled`);
175
+ const complete = reasons.length === 0;
176
+ return {
177
+ complete,
178
+ totalCoverage: complete ? 'modeled-complete' : 'unknown',
179
+ reasons,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Builds the query-specific network. Query (all optional):
185
+ * dataElementIds | dataElementNames restrict SOURCES/SINKS to flows that carry them
186
+ * sources / sinks explicit node ids
187
+ * trustBoundary {from, to} node.trustZone values for sources / sinks
188
+ * nodeIds restrict the network to these nodes
189
+ * family rule family (filters candidates)
190
+ * Edges always come from the full graph.edges set, never a capped path list.
191
+ */
192
+ export function buildCutNetwork(graph, query = {}, candidates = []) {
193
+ const limitations = [];
194
+ const nodes = graph.nodes ?? [];
195
+ const allowed = query.nodeIds ? new Set(query.nodeIds) : null;
196
+ const nodeIds = nodes.map((n) => n.id).filter((id) => !allowed || allowed.has(id));
197
+ const idx = new Map(nodeIds.map((id, i) => [id, i]));
198
+ const N = nodeIds.length;
199
+ const IN = (i) => 2 * i; const OUT = (i) => 2 * i + 1;
200
+ const S = 2 * N; const T = 2 * N + 1;
201
+
202
+ const { eligible, excluded } = screenCandidates(candidates, query);
203
+
204
+ // Sources and sinks.
205
+ let deIds = null;
206
+ if (query.dataElementIds || query.dataElementNames) {
207
+ deIds = new Set(query.dataElementIds ?? []);
208
+ for (const de of graph.dataElements ?? []) {
209
+ if (query.dataElementNames?.includes(de.name)) deIds.add(de.id);
210
+ }
211
+ }
212
+ const carrying = (graph.flows ?? []).filter((f) => !deIds || (f.dataElementIds ?? []).some((d) => deIds.has(d)));
213
+ if (deIds && carrying.some((f) => (f.alternatePathCount ?? 0) > 0)) {
214
+ limitations.push('data-element restriction narrows sources and sinks only; edges come from the full modeled edge set because flow path lists may hide alternates');
215
+ }
216
+ let sourceIds = query.sources ?? [...new Set(carrying.map((f) => f.source))];
217
+ let sinkIds = query.sinks ?? [...new Set(carrying.map((f) => f.sink))];
218
+ if (query.trustBoundary) {
219
+ const { from, to } = query.trustBoundary;
220
+ const zone = new Map(nodes.map((n) => [n.id, n.trustZone]));
221
+ if (from) sourceIds = nodeIds.filter((id) => zone.get(id) === from);
222
+ if (to) sinkIds = nodeIds.filter((id) => zone.get(id) === to);
223
+ if (nodes.every((n) => n.trustZone === undefined)) limitations.push('trust boundary requested but no node carries trustZone; no sources or sinks matched');
224
+ }
225
+ sourceIds = sourceIds.filter((id) => idx.has(id)).sort();
226
+ sinkIds = sinkIds.filter((id) => idx.has(id)).sort();
227
+
228
+ const arcs = [];
229
+ const arcLoc = []; // parallel to arcs: {type,id} or null for structural arcs
230
+ const addArc = (from, to, cap, loc) => { arcs.push({ from, to, cap }); arcLoc.push(loc); };
231
+
232
+ nodeIds.forEach((id, i) => {
233
+ const cand = eligible.get(`node:${id}`);
234
+ addArc(IN(i), OUT(i), cand ? cand.cost : UNCUTTABLE, { type: 'node', id });
235
+ });
236
+
237
+ const existingControls = [];
238
+ let skippedEdges = 0;
239
+ for (const e of [...(graph.edges ?? [])].sort((a, b) => (a.id < b.id ? -1 : 1))) {
240
+ if (!idx.has(e.from) || !idx.has(e.to)) { if (!allowed) skippedEdges++; continue; }
241
+ if (_edgePreProtected(e)) { existingControls.push(e.id); continue; }
242
+ const cand = eligible.get(`edge:${e.id}`);
243
+ addArc(OUT(idx.get(e.from)), IN(idx.get(e.to)), cand ? cand.cost : UNCUTTABLE, { type: 'edge', id: e.id });
244
+ }
245
+ if (skippedEdges > 0) limitations.push(`${skippedEdges} edge(s) reference nodes missing from the graph and were not modeled`);
246
+ for (const id of sourceIds) addArc(S, IN(idx.get(id)), UNCUTTABLE, null);
247
+ for (const id of sinkIds) addArc(OUT(idx.get(id)), T, UNCUTTABLE, null);
248
+
249
+ const usedNodeIds = new Set(nodeIds);
250
+ return {
251
+ nodeIds, idx, arcs, arcLoc, S, T, sourceIds, sinkIds,
252
+ eligible, excludedCandidates: excluded, existingControls, limitations,
253
+ completeness: _assessCompleteness(graph, usedNodeIds),
254
+ edgeCount: arcs.filter((_, i) => arcLoc[i]?.type === 'edge').length,
255
+ };
256
+ }
257
+
258
+ // Reachability (BFS) in the network with `removed` locations cut. Returns
259
+ // parent map for witness paths. Iterative.
260
+ function _reach(net, removedLocKeys, onlySink = null) {
261
+ const adj = new Map();
262
+ net.arcs.forEach((a, i) => {
263
+ const loc = net.arcLoc[i];
264
+ if (loc && removedLocKeys.has(_locKey(loc))) return;
265
+ if (!adj.has(a.from)) adj.set(a.from, []);
266
+ adj.get(a.from).push({ to: a.to, loc });
267
+ });
268
+ const parent = new Map([[net.S, null]]);
269
+ const queue = [net.S];
270
+ for (let qi = 0; qi < queue.length; qi++) {
271
+ const u = queue[qi];
272
+ for (const nx of adj.get(u) ?? []) {
273
+ if (!parent.has(nx.to)) { parent.set(nx.to, { prev: u, loc: nx.loc }); queue.push(nx.to); }
274
+ }
275
+ }
276
+ const sinkReach = net.sinkIds.filter((id) => parent.has(2 * net.idx.get(id) + 1));
277
+ return { parent, reachableSinks: onlySink ? sinkReach.filter((s) => s === onlySink) : sinkReach };
278
+ }
279
+
280
+ function _witness(net, parent, sinkId) {
281
+ const nodePath = []; const edgePath = [];
282
+ let cur = 2 * net.idx.get(sinkId) + 1;
283
+ while (cur !== net.S && cur !== undefined) {
284
+ const p = parent.get(cur);
285
+ if (!p) break;
286
+ if (p.loc?.type === 'edge') edgePath.push(p.loc.id);
287
+ const v = cur >> 1;
288
+ if (cur < 2 * net.nodeIds.length && (nodePath.length === 0 || nodePath[nodePath.length - 1] !== net.nodeIds[v])) nodePath.push(net.nodeIds[v]);
289
+ cur = p.prev;
290
+ }
291
+ return { nodes: nodePath.reverse(), edges: edgePath.reverse() };
292
+ }
293
+
294
+ // ── Scenario mapping ──────────────────────────────────────────────────────
295
+
296
+ /** Translate chosen candidates into scenario-engine operations. */
297
+ export function candidatesToOperations(graph, candidates) {
298
+ const ops = [];
299
+ for (const c of candidates) {
300
+ const { type, id } = c.location;
301
+ if (type === 'edge') {
302
+ if (c.semantics === 'transit_protection') ops.push({ kind: 'require_transit_protection', targetEdgeId: id });
303
+ else if (c.semantics === 'handling') ops.push({ kind: 'apply_handling', targetEdgeId: id, handling: c.handling ?? 'sanitized' });
304
+ continue;
305
+ }
306
+ if (c.semantics === 'remove_entity') { ops.push({ kind: 'remove_entity', targetNodeId: id }); continue; }
307
+ // Node control: guards every path through the node, so apply it to the
308
+ // edges a path must use to pass the node (out-edges of a source node,
309
+ // in-edges otherwise).
310
+ const node = (graph.nodes ?? []).find((n) => n.id === id);
311
+ const isSource = node?.kind === 'source';
312
+ for (const e of graph.edges ?? []) {
313
+ if (isSource ? e.from !== id : e.to !== id) continue;
314
+ if (c.semantics === 'transit_protection') ops.push({ kind: 'require_transit_protection', targetEdgeId: e.id });
315
+ else ops.push({ kind: 'apply_handling', targetEdgeId: e.id, handling: c.handling ?? 'sanitized' });
316
+ }
317
+ }
318
+ return ops;
319
+ }
320
+
321
+ // ── Validation through the scenario engine ────────────────────────────────
322
+
323
+ /**
324
+ * Validates a proposed set of candidates by applying it as a hypothetical
325
+ * scenario and searching the RESULT graph for remaining source-to-sink
326
+ * paths over the full modeled edge set. Returns residual witness paths
327
+ * (display-capped, with the exact reachable-sink count alongside), the
328
+ * certificate, limitations and modeled coverage. Never changes remediation
329
+ * state; `verified` is always false.
330
+ */
331
+ export function validateCutSet(graph, query, chosen, opts = {}) {
332
+ const maxPaths = opts.maxResidualPaths ?? 10;
333
+ const limitations = [];
334
+ const ops = candidatesToOperations(graph, chosen);
335
+ let scenarioGraph = graph;
336
+ let skippedOperations = [];
337
+ if (ops.length > 0) {
338
+ try {
339
+ const r = applyScenario(graph, { operations: ops }, opts.scenarioOpts ?? {});
340
+ scenarioGraph = r.graph; skippedOperations = r.skippedOperations;
341
+ } catch (e) {
342
+ limitations.push(`scenario engine could not apply the proposed controls: ${e.message}`);
343
+ }
344
+ }
345
+ for (const s of skippedOperations) limitations.push(`scenario operation skipped: ${s.reason}`);
346
+
347
+ // Rebuild the network on the scenario graph with NO candidates: any edge
348
+ // the scenario protected is dropped as a pre-existing control, any removed
349
+ // entity is gone, so what remains is exactly the bypass surface.
350
+ const net = buildCutNetwork(scenarioGraph, query, []);
351
+ const { parent, reachableSinks } = _reach(net, new Set());
352
+ const residualPaths = reachableSinks.slice(0, maxPaths).map((s) => ({ sink: s, ..._witness(net, parent, s) }));
353
+ const bypassFound = reachableSinks.length > 0;
354
+
355
+ return {
356
+ hypothetical: true,
357
+ verified: false,
358
+ bypassFound,
359
+ residualSinkCount: reachableSinks.length,
360
+ residualPaths,
361
+ residualPathsTruncated: reachableSinks.length > maxPaths,
362
+ appliedOperationCount: ops.length - skippedOperations.length,
363
+ skippedOperations,
364
+ certificate: {
365
+ kind: 'all-modeled-paths',
366
+ basis: 'full modeled edge set of the scenario graph',
367
+ usedDisplayPathList: false,
368
+ modeledEdgeCount: (scenarioGraph.edges ?? []).length,
369
+ allModeledPathsCut: !bypassFound,
370
+ totalCoverage: net.completeness.totalCoverage,
371
+ scope: net.completeness.complete ? 'complete-modeled-graph' : 'modeled-subgraph-only',
372
+ },
373
+ completeness: net.completeness,
374
+ limitations: [...limitations, ...net.limitations],
375
+ };
376
+ }
377
+
378
+ // ── Planner ───────────────────────────────────────────────────────────────
379
+
380
+ function _locationsFromCut(net, cutArcs) {
381
+ const chosen = [];
382
+ const infinite = [];
383
+ for (const i of cutArcs) {
384
+ const loc = net.arcLoc[i];
385
+ if (!loc) { infinite.push({ type: 'structural' }); continue; }
386
+ const cand = net.eligible.get(_locKey(loc));
387
+ if (cand && net.arcs[i].cap < UNCUTTABLE) chosen.push(cand);
388
+ else infinite.push(loc);
389
+ }
390
+ chosen.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
391
+ return { chosen, uncuttable: infinite };
392
+ }
393
+
394
+ /**
395
+ * Plan a minimum-cost control placement for one query.
396
+ * status: 'complete-cut' | 'partial' | 'no-flow' | 'no-feasible-cut'.
397
+ * 'partial' means some paths cannot be cut by any eligible control; the
398
+ * returned set is the best finite portion and the residual bypass is
399
+ * reported explicitly. A graph that is not known complete downgrades the
400
+ * certificate to modeled-subgraph scope with unknown total coverage.
401
+ */
402
+ export function planCuts(graph, query = {}, candidates = [], opts = {}) {
403
+ const net = buildCutNetwork(graph, query, candidates);
404
+ const base = {
405
+ hypothetical: true,
406
+ verified: false,
407
+ query,
408
+ network: {
409
+ nodeCount: net.nodeIds.length, edgeCount: net.edgeCount,
410
+ sources: net.sourceIds, sinks: net.sinkIds,
411
+ preExistingProtectedEdges: net.existingControls,
412
+ },
413
+ completeness: net.completeness,
414
+ excludedCandidates: net.excludedCandidates,
415
+ limitations: [...net.limitations],
416
+ note: 'Proposed control placement is hypothetical. Only a real rescan through the remediation verification gate can verify a fix.',
417
+ };
418
+ if (net.sourceIds.length === 0 || net.sinkIds.length === 0) {
419
+ return { ...base, status: 'no-flow', cut: { cost: 0, controls: [] }, validation: null,
420
+ limitations: [...base.limitations, 'query selected no sources or no sinks'] };
421
+ }
422
+ const { maxFlow, cutArcs } = maxFlowMinCut(net.arcs.length ? 2 * net.nodeIds.length + 2 : 2, net.arcs, net.S, net.T);
423
+ if (maxFlow <= EPS) {
424
+ return { ...base, status: 'no-flow', cut: { cost: 0, controls: [] },
425
+ validation: validateCutSet(graph, query, [], opts) };
426
+ }
427
+ const { chosen, uncuttable } = _locationsFromCut(net, cutArcs);
428
+ const finiteCost = chosen.reduce((a, c) => a + c.cost, 0);
429
+ const hasUncuttable = maxFlow >= UNCUTTABLE / 2;
430
+ const validation = validateCutSet(graph, query, chosen, opts);
431
+
432
+ // Per-sink minimum cut cost (informational; the joint cut above is what
433
+ // separates every sink at once).
434
+ const perSink = net.sinkIds.map((sid) => {
435
+ const arcs = net.arcs.filter((a) => a.to !== net.T || a.from === 2 * net.idx.get(sid) + 1);
436
+ const r = maxFlowMinCut(2 * net.nodeIds.length + 2, arcs, net.S, net.T);
437
+ return { sink: sid, minCutCost: r.maxFlow >= UNCUTTABLE / 2 ? null : r.maxFlow, cuttable: r.maxFlow < UNCUTTABLE / 2 };
438
+ });
439
+
440
+ let status;
441
+ const limitations = [...base.limitations, ...validation.limitations];
442
+ if (hasUncuttable || validation.bypassFound) {
443
+ status = chosen.length ? 'partial' : 'no-feasible-cut';
444
+ limitations.push('no complete cut exists using eligible controls; residual bypass paths are listed');
445
+ } else {
446
+ status = 'complete-cut';
447
+ }
448
+ if (!net.completeness.complete) {
449
+ limitations.push('graph is not known complete: suggestions are scoped to the modeled subgraph and total coverage is unknown');
450
+ }
451
+ return {
452
+ ...base,
453
+ status,
454
+ cut: { cost: finiteCost, flowValue: hasUncuttable ? null : maxFlow, controls: chosen, optimal: !hasUncuttable,
455
+ optimality: hasUncuttable ? 'not applicable: no complete finite cut exists' : 'minimum cost for this single-query network under the declared capacities' },
456
+ uncuttableLocations: uncuttable,
457
+ perSink,
458
+ validation,
459
+ limitations,
460
+ };
461
+ }
462
+
463
+ // ── Shared / multi-family planning (approximate) ──────────────────────────
464
+
465
+ /**
466
+ * Greedy weighted set cover over per-query minimum cuts. Queries are
467
+ * `{id, ...query}`. Elements are (query, sink) pairs; a set of locations
468
+ * covers an element when removing it disconnects that sink in that query's
469
+ * network (a control only counts in a query where it is eligible, so
470
+ * incompatible families do not leak coverage). APPROXIMATE: no optimality
471
+ * claim. Each selected set is then validated per query.
472
+ */
473
+ export function planSharedControls(graph, queries, candidates, opts = {}) {
474
+ const nets = queries.map((q) => ({ q, net: buildCutNetwork(graph, q, candidates) }));
475
+ const elements = [];
476
+ for (const { q, net } of nets) {
477
+ const { reachableSinks } = _reach(net, new Set());
478
+ for (const s of reachableSinks) elements.push({ query: q.id, sink: s });
479
+ }
480
+ const covers = (net, locKeys, sink) => !_reach(net, locKeys, sink).reachableSinks.includes(sink);
481
+ const eligibleKeyIn = (net, key) => net.eligible.has(key);
482
+
483
+ // Candidate sets: per-query joint cut and per-(query,sink) cuts.
484
+ const sets = [];
485
+ for (const { q, net } of nets) {
486
+ const { maxFlow, cutArcs } = maxFlowMinCut(2 * net.nodeIds.length + 2, net.arcs, net.S, net.T);
487
+ if (maxFlow > EPS && maxFlow < UNCUTTABLE / 2) sets.push(_locationsFromCut(net, cutArcs).chosen);
488
+ for (const sid of net.sinkIds) {
489
+ const arcs = net.arcs.filter((a) => a.to !== net.T || a.from === 2 * net.idx.get(sid) + 1);
490
+ const r = maxFlowMinCut(2 * net.nodeIds.length + 2, arcs, net.S, net.T);
491
+ if (r.maxFlow > EPS && r.maxFlow < UNCUTTABLE / 2) {
492
+ const subnet = { ...net, arcs };
493
+ sets.push(_locationsFromCut(subnet, r.cutArcs).chosen);
494
+ }
495
+ }
496
+ }
497
+ const costOf = new Map();
498
+ for (const s of sets) for (const c of s) costOf.set(_locKey(c.location), c);
499
+
500
+ const coveredBy = (chosenKeys) => {
501
+ const keys = new Set(chosenKeys);
502
+ return elements.filter((el) => {
503
+ const { net } = nets.find((n) => n.q.id === el.query);
504
+ const effective = new Set([...keys].filter((k) => eligibleKeyIn(net, k)));
505
+ return covers(net, effective, el.sink);
506
+ });
507
+ };
508
+
509
+ const chosenKeys = new Set();
510
+ let covered = [];
511
+ for (;;) {
512
+ let bestSet = null; let bestScore = 0; let bestCovered = covered;
513
+ for (const s of sets) {
514
+ const newKeys = s.map((c) => _locKey(c.location)).filter((k) => !chosenKeys.has(k));
515
+ if (newKeys.length === 0) continue;
516
+ const cov = coveredBy([...chosenKeys, ...newKeys]);
517
+ const gain = cov.length - covered.length;
518
+ if (gain <= 0) continue;
519
+ const cost = newKeys.reduce((a, k) => a + costOf.get(k).cost, 0);
520
+ const score = gain / cost;
521
+ if (score > bestScore + 1e-12) { bestScore = score; bestSet = newKeys; bestCovered = cov; }
522
+ }
523
+ if (!bestSet) break;
524
+ for (const k of bestSet) chosenKeys.add(k);
525
+ covered = bestCovered;
526
+ }
527
+
528
+ const controls = [...chosenKeys].sort().map((k) => costOf.get(k));
529
+ const coveredIds = new Set(covered.map((e) => `${e.query}|${e.sink}`));
530
+ const uncovered = elements.filter((e) => !coveredIds.has(`${e.query}|${e.sink}`));
531
+ const perQueryValidation = queries.map((q) => {
532
+ const eligibleHere = controls.filter((c) => nets.find((n) => n.q.id === q.id).net.eligible.has(_locKey(c.location)));
533
+ return { query: q.id, validation: validateCutSet(graph, q, eligibleHere, opts) };
534
+ });
535
+ return {
536
+ hypothetical: true,
537
+ verified: false,
538
+ method: 'greedy weighted set cover over per-query minimum cuts',
539
+ approximate: true,
540
+ optimalityClaim: 'none: the result is not claimed to be globally optimal',
541
+ controls,
542
+ totalCost: controls.reduce((a, c) => a + c.cost, 0),
543
+ coveredElements: covered,
544
+ uncoveredElements: uncovered,
545
+ perQueryValidation,
546
+ limitations: ['approximate shared-control selection; compare against per-query plans before relying on the total'],
547
+ };
548
+ }