@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,186 @@
1
+ // Recursive-component solver for the lineage driver (DAG-01, R01.3 to R01.5).
2
+ //
3
+ // Pure: no I/O, no clock of its own (a budget is an injected callback), and
4
+ // no import of any analysis engine. `driver.js` supplies the per-function
5
+ // analysis; tests supply synthetic transfer functions.
6
+ //
7
+ // ## Fact domain (R01.4)
8
+ //
9
+ // A function summary is a finite set of facts:
10
+ // - `ret:<dataElementId>` the return value carries the id
11
+ // - `mut:<paramPath>:<dataElementId>` the parameter access path
12
+ // `paramPath` is mutated to carry
13
+ // the id
14
+ // The id universe is the set of data-element ids that appear in the entry
15
+ // states of the analyzed contexts and the program text (finite per run). The
16
+ // path universe is the set of syntactic parameter access paths, additionally
17
+ // BOUNDED by `maxPathDepth` (default 6 segments): a deeper path is folded to
18
+ // its depth-bounded prefix, which only adds the id to a coarser path (an
19
+ // over-approximation) and is recorded as the widening reason
20
+ // `path-depth-cap`.
21
+ //
22
+ // ## Join and monotonicity
23
+ //
24
+ // The join is set union per fact family. The solver stores
25
+ // `next = join(previous, analyze(previous))`, so the per-function summary
26
+ // sequence is an ascending chain in a finite lattice whether or not the
27
+ // underlying analysis is itself monotone. An ascending chain over a finite
28
+ // lattice stabilizes, so the iteration terminates; `maxRounds` is a second,
29
+ // independent safety bound. A fixed point reached here is the least fixed
30
+ // point of the joined transfer function, which is exactly what a naive
31
+ // exhaustive iteration to stability computes (the reference solver in the
32
+ // tests).
33
+ //
34
+ // ## What converged means
35
+ //
36
+ // `converged: true` means a full round changed no member summary AND at least
37
+ // two rounds ran (round one is compared with the bottom seed, which is not an
38
+ // analysis result). It does not mean the program is safe: summaries
39
+ // under-approximate whatever the analysis does not model. If a budget stops
40
+ // the iteration, a round cap is hit, or a member is never analyzed, the
41
+ // result is `converged: false` with the remaining frontier listed, and the
42
+ // last iterate must be treated as partial, never as a conservative proof.
43
+
44
+ function cmp(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
45
+
46
+ export const DEFAULT_MAX_PATH_DEPTH = 6;
47
+ export const DEFAULT_MAX_ROUNDS = 32;
48
+ export const DEFAULT_MAX_CHANGED_FACTS = 1000;
49
+
50
+ function foldPath(path, maxDepth) {
51
+ const segs = String(path).split('.');
52
+ if (segs.length <= maxDepth) return null;
53
+ return segs.slice(0, maxDepth).join('.');
54
+ }
55
+
56
+ // Joins two field-identity summaries. Returns `{ summary, reasons }`.
57
+ // `reasons` lists widening reasons applied by this join (deterministic order).
58
+ export function joinFieldSummaries(prev, next, opts = {}) {
59
+ const maxDepth = opts.maxPathDepth ?? DEFAULT_MAX_PATH_DEPTH;
60
+ const reasons = new Set();
61
+ const returnFlat = new Set(prev ? prev.returnFlat : []);
62
+ for (const id of next.returnFlat) returnFlat.add(id);
63
+
64
+ const mutatedParams = new Map();
65
+ const addMut = (path, ids) => {
66
+ const folded = foldPath(path, maxDepth);
67
+ const key = folded ?? path;
68
+ if (folded !== null) reasons.add('path-depth-cap');
69
+ let set = mutatedParams.get(key);
70
+ if (!set) { set = new Set(); mutatedParams.set(key, set); }
71
+ for (const id of ids) set.add(id);
72
+ };
73
+ if (prev) for (const [p, ids] of prev.mutatedParams) addMut(p, ids);
74
+ for (const [p, ids] of next.mutatedParams) addMut(p, ids);
75
+
76
+ const returnByPath = new Map();
77
+ for (const src of [prev && prev.returnByPath, next.returnByPath]) {
78
+ if (!src) continue;
79
+ for (const [p, ids] of src) {
80
+ const set = returnByPath.get(p) ?? new Set();
81
+ for (const id of ids) set.add(id);
82
+ returnByPath.set(p, set);
83
+ }
84
+ }
85
+ const summary = { returnFlat, returnByPath, mutatedParams, widenings: next.widenings ?? [] };
86
+ if (next.degradedReason) summary.degradedReason = next.degradedReason;
87
+ return { summary, reasons: [...reasons].sort(cmp) };
88
+ }
89
+
90
+ export function fieldFacts(summary) {
91
+ const out = [];
92
+ if (!summary) return out;
93
+ for (const id of summary.returnFlat) out.push(`ret:${id}`);
94
+ for (const [p, ids] of summary.mutatedParams) for (const id of ids) out.push(`mut:${p}:${id}`);
95
+ return out.sort(cmp);
96
+ }
97
+
98
+ export function bottomFieldSummary() {
99
+ return { returnFlat: new Set(), returnByPath: new Map(), mutatedParams: new Map(), widenings: [] };
100
+ }
101
+
102
+ function factsEqual(a, b) {
103
+ const fa = fieldFacts(a);
104
+ const fb = fieldFacts(b);
105
+ if (fa.length !== fb.length) return false;
106
+ for (let i = 0; i < fa.length; i++) if (fa[i] !== fb[i]) return false;
107
+ return true;
108
+ }
109
+
110
+ // Solves one recursive component.
111
+ //
112
+ // members sorted qids in the component
113
+ // analyze(qid) runs one analysis of `qid` against the currently
114
+ // published summaries and returns a RAW summary
115
+ // publish(qid, s) makes `s` the summary other members will read
116
+ // bottom(qid) initial summary (default: empty)
117
+ // beforeRound(round) hook, called before each round
118
+ // shouldStop() returns a reason string to stop, or a falsy value
119
+ // maxRounds, maxPathDepth, maxChangedFacts bounds
120
+ //
121
+ // Returns { converged, rounds, changedFacts, changedFactsTotal,
122
+ // changedFactsTruncated, wideningReasons, stoppedBy,
123
+ // remainingFrontier, summaries }.
124
+ export function solveRecursiveComponent(args) {
125
+ const members = [...args.members].sort(cmp);
126
+ const maxRounds = args.maxRounds ?? DEFAULT_MAX_ROUNDS;
127
+ const maxChanged = args.maxChangedFacts ?? DEFAULT_MAX_CHANGED_FACTS;
128
+ const bottom = args.bottom || (() => bottomFieldSummary());
129
+ const current = new Map();
130
+ for (const q of members) {
131
+ const b = bottom(q);
132
+ current.set(q, b);
133
+ args.publish(q, b);
134
+ }
135
+ const reasons = new Set();
136
+ const changedFacts = [];
137
+ let changedTotal = 0;
138
+ let rounds = 0;
139
+ let converged = false;
140
+ let stoppedBy = null;
141
+ let analyzedThisRound = new Set();
142
+
143
+ while (rounds < maxRounds) {
144
+ rounds++;
145
+ if (args.beforeRound) args.beforeRound(rounds);
146
+ let changedInRound = false;
147
+ analyzedThisRound = new Set();
148
+ for (const q of members) {
149
+ const stop = args.shouldStop ? args.shouldStop() : null;
150
+ if (stop) { stoppedBy = stop; break; }
151
+ const prev = current.get(q);
152
+ const raw = args.analyze(q);
153
+ analyzedThisRound.add(q);
154
+ const { summary: joined, reasons: rs } = joinFieldSummaries(prev, raw, { maxPathDepth: args.maxPathDepth });
155
+ for (const r of rs) reasons.add(r);
156
+ if (!factsEqual(prev, joined)) {
157
+ changedInRound = true;
158
+ const before = new Set(fieldFacts(prev));
159
+ for (const f of fieldFacts(joined)) {
160
+ if (before.has(f)) continue;
161
+ changedTotal++;
162
+ if (changedFacts.length < maxChanged) changedFacts.push({ round: rounds, qid: q, fact: f });
163
+ }
164
+ }
165
+ current.set(q, joined);
166
+ args.publish(q, joined);
167
+ }
168
+ if (stoppedBy) { rounds = analyzedThisRound.size === 0 && rounds > 0 ? rounds - 1 : rounds; break; }
169
+ if (!changedInRound && rounds >= 2) { converged = true; break; }
170
+ }
171
+ if (!converged && !stoppedBy && rounds >= maxRounds) {
172
+ reasons.add('round-cap');
173
+ stoppedBy = 'round-cap';
174
+ }
175
+ return {
176
+ converged,
177
+ rounds,
178
+ changedFacts,
179
+ changedFactsTotal: changedTotal,
180
+ changedFactsTruncated: changedTotal > changedFacts.length,
181
+ wideningReasons: [...reasons].sort(cmp),
182
+ stoppedBy,
183
+ remainingFrontier: converged ? [] : members.slice(),
184
+ summaries: current,
185
+ };
186
+ }
@@ -0,0 +1,174 @@
1
+ //
2
+ // smt-orchestrator.js: the optional solver path for witness conditions (DAG-04, R04.3 / R04.4).
3
+ //
4
+ // The bounded abstract domains in path-predicates.js decide first. A solver is consulted only when they
5
+ // say `unknown`, and only through this module, which makes the call:
6
+ // * awaited: the caller gets one settled result, never a dangling promise;
7
+ // * time-bounded: a deadline races the solver, and a solver that ignores it is still released;
8
+ // * cancellable: an AbortSignal from the caller and the module's own deadline both abort the solver's
9
+ // signal, and a late settlement from an abandoned solver is swallowed;
10
+ // * honest: no solver reports `unsupported`, a deadline reports `timeout`, a malformed or failing solver
11
+ // reports `unknown`. None of these ever drops or downgrades the finding. The abstract result is kept.
12
+ //
13
+ // `noSolver` is the default stub. The real backend is smt-z3-backend.js (optional `z3-solver`, lazy, used only
14
+ // when a solver is passed in or AGENTIC_SECURITY_LINEAGE_SMT=z3 is set). A solver is an
15
+ // object with `{ name, version?, available?, check(problem, { signal }) -> Promise<{ result }> }` where
16
+ // `result` is 'sat' | 'unsat' | 'unknown'. `problem` is the solver-neutral view from `toSolverProblem`.
17
+ // Satisfiability never implies exploitability.
18
+
19
+ import { evaluateWitnessConditions, toSolverProblem, PATH_PREDICATE_VERSION, SATISFIABILITY_NOTE } from './path-predicates.js';
20
+ import { sharedZ3Solver, smtBackendEnabled } from './smt-z3-backend.js';
21
+
22
+ export const SOLVER_DEFAULT_TIMEOUT_MS = 1000;
23
+ export const SOLVER_TIMEOUT_MAX_MS = 60_000;
24
+
25
+ /** The default: a solver that is not there. */
26
+ export const noSolver = Object.freeze({
27
+ name: 'none', version: null, available: false,
28
+ async check() { return { result: 'unsupported' }; },
29
+ });
30
+
31
+ const MAP = Object.freeze({ sat: 'feasible-model', unsat: 'infeasible-under-model', unknown: 'unknown', timeout: 'timeout' });
32
+
33
+ /**
34
+ * The solver a call uses. An explicit `solver` always wins. Otherwise the real z3 backend is used only when
35
+ * AGENTIC_SECURITY_LINEAGE_SMT=z3 is set (it loads lazily and reports `unsupported` if the optional package
36
+ * is missing); with nothing set the default is `noSolver`, so a default run never loads a solver.
37
+ */
38
+ export function resolveSolver(solver, env = process.env) {
39
+ if (solver !== undefined) return solver;
40
+ return smtBackendEnabled(env) ? sharedZ3Solver() : noSolver;
41
+ }
42
+
43
+ /** Render the problem as SMT-LIB 2 text for a solver adapter that wants text. Opaque predicates become comments. */
44
+ export function toSmtLib(problem) {
45
+ const lines = ['(set-logic ALL)'];
46
+ const sym = (s) => `|${String(s).replace(/\|/g, '_')}|`;
47
+ const num = (side) => (side.var ? sym(side.var) : String(side.const));
48
+ for (const v of problem.vars) lines.push(`(declare-const ${sym(v)} Int)`);
49
+ for (const a of problem.assertions) {
50
+ const alts = a.alternatives.map((alt) => {
51
+ const parts = alt.map((l) => {
52
+ if (l.t === 'num') {
53
+ const op = { '<': '<', '<=': '<=', '>': '>', '>=': '>=', '==': '=', '===': '=', '!=': 'distinct', '!==': 'distinct' }[l.op];
54
+ return `(${op} ${num(l.left)} ${num(l.right)})`;
55
+ }
56
+ if (l.t === 'eq') return `(${l.equal ? '=' : 'distinct'} ${sym(l.var)} ${JSON.stringify(l.value)})`;
57
+ if (l.t === 'bool') return l.truth ? 'true' : 'false';
58
+ return 'true';
59
+ });
60
+ return parts.length === 1 ? parts[0] : `(and ${parts.join(' ') || 'true'})`;
61
+ });
62
+ lines.push(`; ${a.ref}`);
63
+ lines.push(`(assert ${alts.length === 0 ? 'false' : alts.length === 1 ? alts[0] : `(or ${alts.join(' ')})`})`);
64
+ }
65
+ lines.push('(check-sat)');
66
+ return lines.join('\n');
67
+ }
68
+
69
+ function boundedTimeout(ms) {
70
+ if (ms === undefined) return SOLVER_DEFAULT_TIMEOUT_MS;
71
+ if (!Number.isFinite(ms) || ms < 0 || ms > SOLVER_TIMEOUT_MAX_MS) return null;
72
+ return Math.floor(ms);
73
+ }
74
+
75
+ /**
76
+ * Ask a solver about the collected conditions. Always resolves; never throws.
77
+ *
78
+ * @returns {Promise<{status:string, reasons:string[], solver:object, elapsedMs:number, semanticsVersion:string, limitations:string[]}>}
79
+ */
80
+ export async function checkWithSolver(collected, { solver: solverOpt, timeoutMs, signal, now = Date.now } = {}) {
81
+ const solver = resolveSolver(solverOpt);
82
+ const started = now();
83
+ const info = { name: solver?.name ?? 'unknown', version: solver?.version ?? null };
84
+ const out = (status, reasons, extra = {}) => ({
85
+ status, reasons, solver: info, elapsedMs: now() - started, semanticsVersion: PATH_PREDICATE_VERSION,
86
+ satisfiabilityNote: SATISFIABILITY_NOTE,
87
+ limitations: ['A solver result is relative to the encoded conditions, not to the running program.', ...(extra.limitations ?? [])],
88
+ ...(extra.model ? { model: extra.model } : {}),
89
+ });
90
+ const tmo = boundedTimeout(timeoutMs);
91
+ if (tmo === null) return out('unknown', ['invalid-timeout']);
92
+ if (!solver || solver.available === false || typeof solver.check !== 'function') return out('unsupported', ['no-solver']);
93
+ if (signal?.aborted) return out('unknown', ['cancelled']);
94
+
95
+ const ac = new AbortController();
96
+ let timer; let onAbort;
97
+ const timeoutP = new Promise((resolve) => { timer = setTimeout(() => resolve({ __timeout: true }), tmo); });
98
+ const cancelP = new Promise((resolve) => {
99
+ if (!signal) return;
100
+ onAbort = () => resolve({ __cancelled: true });
101
+ signal.addEventListener('abort', onAbort, { once: true });
102
+ });
103
+ let solverP;
104
+ try {
105
+ solverP = Promise.resolve(solver.check(toSolverProblem(collected), { signal: ac.signal }));
106
+ } catch {
107
+ clearTimeout(timer);
108
+ return out('unknown', ['solver-error']);
109
+ }
110
+ // A solver that settles after we gave up must not become an unhandled rejection.
111
+ solverP.catch(() => {});
112
+ try {
113
+ const r = await Promise.race([solverP, timeoutP, cancelP]);
114
+ if (r && r.__timeout) { ac.abort(); return out('timeout', ['solver-deadline']); }
115
+ if (r && r.__cancelled) { ac.abort(); return out('unknown', ['cancelled']); }
116
+ const status = isObjectWithResult(r) ? MAP[r.result] : undefined;
117
+ if (r?.result === 'unsupported') return out('unsupported', ['solver-declined', ...(typeof r.reason === 'string' ? [r.reason] : [])]);
118
+ if (!status) return out('unknown', ['solver-malformed']);
119
+ const why = typeof r.reason === 'string' ? [r.reason] : [];
120
+ return out(status, [`solver-${r.result}`, ...why], r.result === 'sat' && r.model && typeof r.model === 'object' ? { model: r.model } : {});
121
+ } catch {
122
+ return out('unknown', ['solver-error']);
123
+ } finally {
124
+ clearTimeout(timer);
125
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort);
126
+ }
127
+ }
128
+
129
+ function isObjectWithResult(r) { return typeof r === 'object' && r !== null && typeof r.result === 'string'; }
130
+
131
+ /**
132
+ * The full decision: abstract domains first, then the solver only when the abstract answer is `unknown`.
133
+ * The abstract answer stays on the result, so a timeout or an unsupported solver never erases what the
134
+ * cheap model already established, and the finding stays visible as unknown/partial.
135
+ */
136
+ export async function evaluateWitnessConditionsAsync(collected, o = {}) {
137
+ const abstract = evaluateWitnessConditions(collected, o);
138
+ const consultFeasible = o.consultOnFeasible ?? smtBackendEnabled();
139
+ const refine = abstract.status === 'feasible-model' && consultFeasible && abstract.clauseRefs.length > 0;
140
+ if (abstract.status !== 'unknown' && !refine) return { ...abstract, abstractStatus: abstract.status, solverConsulted: false };
141
+ const s = await checkWithSolver(collected, o);
142
+ const kept = { ...abstract, abstractStatus: abstract.status };
143
+ // Branch arms of one branch inside a loop may belong to different iterations. The solver's problem carries no
144
+ // iteration structure, so its `unsat` there is not evidence of infeasibility: the answer stays `unknown`.
145
+ const loopArms = abstract.reasons.includes('branch-arms-in-loop');
146
+ if (refine) {
147
+ // On a feasible-model answer ONLY a solver unsat changes anything; every other outcome keeps the abstract result.
148
+ if (s.status === 'infeasible-under-model' && !loopArms) return unsatResult(abstract, s);
149
+ return { ...kept, solverConsulted: s.status !== 'unsupported', solver: s.solver, solverStatus: s.status, elapsedMs: s.elapsedMs };
150
+ }
151
+ if (s.status === 'unsupported') return { ...kept, solverConsulted: false, solver: s.solver, solverStatus: 'unsupported' };
152
+ if (s.status === 'timeout') {
153
+ return { ...kept, status: 'timeout', reasons: [...abstract.reasons, 'solver-deadline'], solverConsulted: true, solver: s.solver, solverStatus: 'timeout', elapsedMs: s.elapsedMs };
154
+ }
155
+ if (s.status === 'infeasible-under-model' && loopArms) {
156
+ return { ...kept, solverConsulted: true, solver: s.solver, solverStatus: 'unknown', reasons: [...abstract.reasons, 'solver-unsat-not-trusted-in-loop'], elapsedMs: s.elapsedMs };
157
+ }
158
+ if (s.status === 'unknown') {
159
+ return { ...kept, solverConsulted: true, solver: s.solver, solverStatus: 'unknown', reasons: [...abstract.reasons, ...s.reasons], elapsedMs: s.elapsedMs };
160
+ }
161
+ if (s.status === 'infeasible-under-model') return unsatResult(abstract, s);
162
+ return {
163
+ ...kept, status: s.status, reasons: s.reasons, solverConsulted: true, solver: s.solver, solverStatus: s.status,
164
+ limitations: [...abstract.limitations, ...s.limitations], elapsedMs: s.elapsedMs, contradiction: null,
165
+ };
166
+ }
167
+
168
+ function unsatResult(abstract, s) {
169
+ return {
170
+ ...abstract, status: 'infeasible-under-model', reasons: s.reasons, abstractStatus: abstract.status, solverConsulted: true, solver: s.solver, solverStatus: s.status,
171
+ limitations: [...abstract.limitations, ...s.limitations], elapsedMs: s.elapsedMs,
172
+ contradiction: { kind: 'solver-unsat', refs: abstract.clauseRefs },
173
+ };
174
+ }
@@ -0,0 +1,294 @@
1
+ //
2
+ // smt-z3-backend.js: a real solver for the SMT orchestrator (DAG-04, R04.3 / R04.4).
3
+ //
4
+ // The orchestrator (smt-orchestrator.js) is built against an injectable backend object
5
+ // { name, version?, available?, check(problem, { signal }) -> Promise<{ result, reason? }> }.
6
+ // This module is that backend, on top of the optional `z3-solver` package. Rules it keeps:
7
+ //
8
+ // * LAZY and OPTIONAL. `z3-solver` is imported on the first query, never at module load, and never by a
9
+ // default scan (the whole path is behind `pathEvidence` and `AGENTIC_SECURITY_LINEAGE_SMT=z3`). A package
10
+ // that is not installed, fails to load or fails to initialise makes the backend `unsupported`
11
+ // (`available === false`): never a crash and never an `unsat`.
12
+ // * ONLY A VERIFIED ENCODING MAY SAY `unsat`. `encodeProblem` refuses (result `unknown`, with a reason) any
13
+ // problem it cannot translate faithfully: a variable used with two sorts, a number SMT-LIB cannot write
14
+ // exactly, an unknown literal or operator. An opaque (unmodeled) predicate is encoded as `true`, which can
15
+ // only make the formula LARGER, so `unsat` stays sound; for the same reason a `sat` that leaned on an opaque
16
+ // predicate is reported `unknown`, not as a model.
17
+ // * Numbers are Real, not Int, so a comparison such as `0.5 < x < 1` is not wrongly called infeasible.
18
+ // * BOUNDED. A per-query timeout (`timeoutMs`), a process memory cap (`memoryMb`, Z3's `memory_max_size`),
19
+ // and the orchestrator's own deadline racing the query. A timeout is `timeout`, memory exhaustion and any
20
+ // other solver failure is `unknown`. None of them ever reduces anything.
21
+ // * DETERMINISTIC. Variables are renamed v0..vN in sorted order, assertions keep their order, the solver
22
+ // random seed is fixed, and the SMT-LIB text is the cache key. Only definitive answers (`sat`, `unsat`)
23
+ // are cached; a timeout depends on the machine and is not.
24
+ //
25
+ // `unsat` here means "the encoded conditions cannot hold together", relative to the model stated in
26
+ // path-predicates.js. It is not a proof about the running program, and `sat` never implies exploitability.
27
+
28
+ import crypto from 'node:crypto';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const require = createRequire(import.meta.url);
32
+
33
+ export const Z3_BACKEND_VERSION = 'smt-z3/1';
34
+ export const Z3_DEFAULT_TIMEOUT_MS = 1000;
35
+ export const Z3_DEFAULT_MEMORY_MB = 256;
36
+ const CACHE_MAX = 256;
37
+ const HARD_GRACE_MS = 500;
38
+ // 1e21 and 1e-7 are where String(number) switches to exponent form, which SMT-LIB has no literal for.
39
+ const MAX_PLAIN = 1e15;
40
+
41
+ // ---- encoding ------------------------------------------------------------------------------------------
42
+
43
+ const OPS = { '<': '<', '<=': '<=', '>': '>', '>=': '>=', '==': '=', '===': '=', '!=': 'distinct', '!==': 'distinct' };
44
+
45
+ /** A finite JS number as an SMT-LIB Real literal, or null when it cannot be written exactly in plain decimal. */
46
+ function realLiteral(n) {
47
+ if (typeof n !== 'number' || !Number.isFinite(n) || Math.abs(n) >= MAX_PLAIN) return null;
48
+ const s = String(n);
49
+ if (/e/i.test(s)) return null;
50
+ const abs = Math.abs(n);
51
+ const body = Number.isInteger(abs) ? `${abs}.0` : String(abs);
52
+ return n < 0 ? `(- ${body})` : body;
53
+ }
54
+
55
+ /** A JS string as an SMT-LIB 2.6 string literal: only printable ASCII is written raw. */
56
+ function stringLiteral(s) {
57
+ let out = '';
58
+ for (const ch of String(s)) {
59
+ const cp = ch.codePointAt(0);
60
+ if (ch === '"') out += '""';
61
+ else if (cp >= 0x20 && cp <= 0x7e && ch !== '\\') out += ch;
62
+ else out += `\\u{${cp.toString(16)}}`;
63
+ }
64
+ return `"${out}"`;
65
+ }
66
+
67
+ /**
68
+ * Translate the solver-neutral problem (path-predicates.js `toSolverProblem`) into SMT-LIB 2 text.
69
+ *
70
+ * @returns {{ok:true, text:string, digest:string, unmodeled:number, sorts:object, literals:number, assertions:number}
71
+ * | {ok:false, reason:string}}
72
+ */
73
+ export function encodeProblem(problem) {
74
+ if (!problem || typeof problem !== 'object' || !Array.isArray(problem.assertions) || !Array.isArray(problem.vars)) {
75
+ return { ok: false, reason: 'encoding-unverified:malformed-problem' };
76
+ }
77
+ const sorts = new Map();
78
+ const claim = (v, sort) => {
79
+ if (typeof v !== 'string') return false;
80
+ if (sorts.has(v) && sorts.get(v) !== sort) return false;
81
+ sorts.set(v, sort);
82
+ return true;
83
+ };
84
+ const constants = problem.constants && typeof problem.constants === 'object' ? problem.constants : {};
85
+ let literals = 0; let unmodeled = 0;
86
+ const alt = [];
87
+ for (const a of problem.assertions) {
88
+ if (!a || !Array.isArray(a.alternatives)) return { ok: false, reason: 'encoding-unverified:malformed-assertion' };
89
+ for (const conj of a.alternatives) {
90
+ if (!Array.isArray(conj)) return { ok: false, reason: 'encoding-unverified:malformed-assertion' };
91
+ for (const l of conj) {
92
+ literals += 1;
93
+ if (!l || typeof l !== 'object') return { ok: false, reason: 'encoding-unverified:malformed-literal' };
94
+ if (l.t === 'num') {
95
+ if (!OPS[l.op]) return { ok: false, reason: 'encoding-unverified:unknown-operator' };
96
+ for (const s of [l.left, l.right]) {
97
+ if (!s || typeof s !== 'object') return { ok: false, reason: 'encoding-unverified:malformed-literal' };
98
+ if ('var' in s) { if (!claim(s.var, 'Real')) return { ok: false, reason: 'encoding-unverified:sort-conflict' }; }
99
+ else if (realLiteral(s.const) === null) return { ok: false, reason: 'encoding-unverified:number-not-encodable' };
100
+ }
101
+ } else if (l.t === 'eq') {
102
+ if (typeof l.value !== 'string') return { ok: false, reason: 'encoding-unverified:non-string-equality' };
103
+ if (!claim(l.var, 'String')) return { ok: false, reason: 'encoding-unverified:sort-conflict' };
104
+ } else if (l.t === 'bool') {
105
+ if (!claim(l.var, 'Bool')) return { ok: false, reason: 'encoding-unverified:sort-conflict' };
106
+ } else if (l.t === 'opaque') {
107
+ unmodeled += 1;
108
+ } else {
109
+ return { ok: false, reason: 'encoding-unverified:unknown-literal' };
110
+ }
111
+ }
112
+ }
113
+ }
114
+ const constEntries = [];
115
+ for (const [k, v] of Object.entries(constants)) {
116
+ if (!sorts.has(k)) continue; // a constant for a variable no condition reads constrains nothing
117
+ if (typeof v === 'number') {
118
+ if (!claim(k, 'Real')) return { ok: false, reason: 'encoding-unverified:sort-conflict' };
119
+ if (realLiteral(v) === null) return { ok: false, reason: 'encoding-unverified:number-not-encodable' };
120
+ } else if (typeof v === 'string') {
121
+ if (!claim(k, 'String')) return { ok: false, reason: 'encoding-unverified:sort-conflict' };
122
+ } else return { ok: false, reason: 'encoding-unverified:constant-type' };
123
+ constEntries.push([k, v]);
124
+ }
125
+ // Rename to v0..vN in sorted order: no escaping hazard, and two distinct names can never merge.
126
+ const names = [...sorts.keys()].sort();
127
+ const id = new Map(names.map((n, i) => [n, `v${i}`]));
128
+ const lines = ['(set-logic ALL)'];
129
+ for (const n of names) lines.push(`(declare-const ${id.get(n)} ${sorts.get(n)})`);
130
+ const side = (s) => ('var' in s ? id.get(s.var) : realLiteral(s.const));
131
+ let asserted = 0;
132
+ for (const a of problem.assertions) {
133
+ const alts = a.alternatives.map((conj) => {
134
+ const parts = conj.map((l) => {
135
+ if (l.t === 'num') return `(${OPS[l.op]} ${side(l.left)} ${side(l.right)})`;
136
+ if (l.t === 'eq') return l.equal ? `(= ${id.get(l.var)} ${stringLiteral(l.value)})` : `(distinct ${id.get(l.var)} ${stringLiteral(l.value)})`;
137
+ if (l.t === 'bool') return l.truth ? id.get(l.var) : `(not ${id.get(l.var)})`;
138
+ return 'true'; // opaque: no constraint
139
+ });
140
+ return parts.length === 0 ? 'true' : parts.length === 1 ? parts[0] : `(and ${parts.join(' ')})`;
141
+ });
142
+ lines.push(`(assert ${alts.length === 0 ? 'false' : alts.length === 1 ? alts[0] : `(or ${alts.join(' ')})`})`);
143
+ asserted += 1;
144
+ }
145
+ for (const [k, v] of constEntries.sort((x, y) => (x[0] < y[0] ? -1 : 1))) {
146
+ lines.push(`(assert (= ${id.get(k)} ${typeof v === 'number' ? realLiteral(v) : stringLiteral(v)}))`);
147
+ asserted += 1;
148
+ }
149
+ lines.push('(check-sat)');
150
+ const text = lines.join('\n');
151
+ // Structural self-check of the translation: every assertion of the problem became exactly one assert.
152
+ const emitted = lines.filter((l) => l.startsWith('(assert ')).length;
153
+ if (emitted !== problem.assertions.length + constEntries.length || asserted !== emitted) {
154
+ return { ok: false, reason: 'encoding-unverified:assertion-count' };
155
+ }
156
+ return {
157
+ ok: true, text, unmodeled, literals, assertions: emitted,
158
+ sorts: Object.fromEntries(names.map((n) => [n, sorts.get(n)])),
159
+ digest: crypto.createHash('sha256').update(text).digest('hex').slice(0, 16),
160
+ };
161
+ }
162
+
163
+ // ---- the backend ---------------------------------------------------------------------------------------
164
+
165
+ /**
166
+ * The default loader. Loaded through a createRequire call at first use, the way the optional tree-sitter
167
+ * runtime is: a bundler keeps it external, and a build without the package fails here (reported as
168
+ * unsupported by the caller), never at start-up.
169
+ */
170
+ async function defaultLoader() {
171
+ return require('z3-solver');
172
+ }
173
+
174
+ /**
175
+ * Build a z3 backend.
176
+ *
177
+ * @param {object} [o]
178
+ * @param {number} [o.timeoutMs] per-query solver timeout (Z3 `timeout`)
179
+ * @param {number} [o.memoryMb] process memory cap handed to Z3 (`memory_max_size`)
180
+ * @param {()=>Promise<object>} [o.loader] returns the `z3-solver` module; injected by tests
181
+ */
182
+ export function createZ3Solver({ timeoutMs = Z3_DEFAULT_TIMEOUT_MS, memoryMb = Z3_DEFAULT_MEMORY_MB, loader = defaultLoader } = {}) {
183
+ const state = { initP: null, failed: null, api: null, ctx: null, version: null, queue: Promise.resolve() };
184
+ const cache = new Map();
185
+ const stats = { queries: 0, cacheHits: 0, loads: 0 };
186
+
187
+ function init() {
188
+ if (state.initP) return state.initP;
189
+ state.initP = (async () => {
190
+ stats.loads += 1;
191
+ const mod = await loader();
192
+ const initFn = mod?.init ?? mod?.default?.init;
193
+ if (typeof initFn !== 'function') throw new Error('z3-solver has no init()');
194
+ const api = await initFn();
195
+ try { api.Z3.global_param_set('memory_max_size', String(memoryMb)); } catch { /* an older build without the knob */ }
196
+ state.api = api;
197
+ state.ctx = api.Context('lineage-smt');
198
+ try { state.version = api.getVersionString?.() ?? null; } catch { state.version = null; }
199
+ return state;
200
+ })().catch((e) => {
201
+ state.failed = String(e?.code || e?.name || 'load-error');
202
+ state.initP = null;
203
+ return null;
204
+ });
205
+ return state.initP;
206
+ }
207
+
208
+ async function run(enc, signal) {
209
+ const ctx = state.ctx;
210
+ const solver = new ctx.Solver();
211
+ let abortHook;
212
+ try {
213
+ solver.set('timeout', timeoutMs);
214
+ try { solver.set('random_seed', 0); } catch { /* not a parameter of this build */ }
215
+ solver.fromString(enc.text);
216
+ if (signal) {
217
+ abortHook = () => { try { state.api.Z3.interrupt(ctx.ptr); } catch { /* best effort */ } };
218
+ signal.addEventListener('abort', abortHook, { once: true });
219
+ }
220
+ // Z3 honours `timeout`, but a wedged wasm call must not hold the queue forever: past a grace period the
221
+ // backend gives up on this context and reports itself unavailable from then on.
222
+ let hardTimer;
223
+ const hung = new Promise((resolve) => { hardTimer = setTimeout(() => resolve('hung'), timeoutMs + HARD_GRACE_MS); });
224
+ const verdict = await Promise.race([solver.check(), hung]).finally(() => clearTimeout(hardTimer));
225
+ if (verdict === 'hung') {
226
+ try { state.api.Z3.interrupt(ctx.ptr); } catch { /* best effort */ }
227
+ state.failed = 'hung';
228
+ return { result: 'timeout', reason: 'z3-hung' };
229
+ }
230
+ if (verdict === 'unsat') return { result: 'unsat' };
231
+ if (verdict === 'sat') return { result: 'sat' };
232
+ const why = String(solver.reasonUnknown?.() ?? '');
233
+ if (/timeout/i.test(why)) return { result: 'timeout', reason: 'z3-timeout' };
234
+ if (/memout|memory/i.test(why)) return { result: 'unknown', reason: 'z3-memory' };
235
+ if (/cancel/i.test(why)) return { result: 'unknown', reason: 'z3-cancelled' };
236
+ return { result: 'unknown', reason: 'z3-unknown' };
237
+ } finally {
238
+ if (abortHook && signal) signal.removeEventListener('abort', abortHook);
239
+ }
240
+ }
241
+
242
+ const backend = {
243
+ name: 'z3',
244
+ get version() { return state.version ? `${state.version}+${Z3_BACKEND_VERSION}` : Z3_BACKEND_VERSION; },
245
+ /** False only after a load/initialisation failure; true (unknown yet) before the first query. */
246
+ get available() { return state.failed === null; },
247
+ get loadFailure() { return state.failed; },
248
+ stats() { return { ...stats, cacheSize: cache.size }; },
249
+ /** Never throws. `unsupported` when z3 is missing, `unknown` when the encoding cannot be verified. */
250
+ async check(problem, { signal } = {}) {
251
+ stats.queries += 1;
252
+ const enc = encodeProblem(problem);
253
+ if (!enc.ok) return { result: 'unknown', reason: enc.reason };
254
+ const key = `${timeoutMs}|${enc.text}`;
255
+ const hit = cache.get(key);
256
+ if (hit) { stats.cacheHits += 1; cache.delete(key); cache.set(key, hit); return finish(hit, enc, true); }
257
+ const ready = await init();
258
+ if (!ready || state.failed) return { result: 'unsupported', reason: `z3-unavailable:${state.failed}` };
259
+ // One query at a time: the wasm context is single-threaded.
260
+ const turn = state.queue.then(() => run(enc, signal)).catch(() => ({ result: 'unknown', reason: 'z3-error' }));
261
+ state.queue = turn.then(() => {}, () => {});
262
+ const raw = await turn;
263
+ if (raw.result === 'sat' || raw.result === 'unsat') {
264
+ cache.set(key, raw);
265
+ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
266
+ }
267
+ return finish(raw, enc, false);
268
+ },
269
+ /** Release the wasm worker threads. The next query re-initialises. */
270
+ async shutdown() {
271
+ const api = state.api;
272
+ state.initP = null; state.api = null; state.ctx = null;
273
+ if (!api) return;
274
+ try { const kt = (await loader())?.killThreads ?? null; if (kt && api.em) kt(api.em); } catch { /* nothing to release */ }
275
+ },
276
+ };
277
+ return backend;
278
+ }
279
+
280
+ function finish(raw, enc, cached) {
281
+ // A model that leaned on an unmodeled predicate says nothing about the real conditions.
282
+ if (raw.result === 'sat' && enc.unmodeled > 0) return { result: 'unknown', reason: 'sat-with-unmodeled-predicates', encodingDigest: enc.digest, cached };
283
+ return { ...raw, encodingDigest: enc.digest, cached };
284
+ }
285
+
286
+ let _shared = null;
287
+ /** The process-wide backend used when AGENTIC_SECURITY_LINEAGE_SMT=z3. */
288
+ export function sharedZ3Solver() {
289
+ if (!_shared) _shared = createZ3Solver();
290
+ return _shared;
291
+ }
292
+ export function smtBackendEnabled(env = process.env) {
293
+ return env.AGENTIC_SECURITY_LINEAGE_SMT === 'z3';
294
+ }