@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.
- package/CHANGELOG.md +37 -0
- package/bin/agentic-security.js +111 -1
- package/dist/1128.index.js +912 -0
- package/dist/1665.index.js +828 -0
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{dad58d1113a12c23a142.json → ca62ee93079be4c24eee.json} +29 -12
- package/dist/frontend/src/shell.js +12 -2
- package/package.json +29 -12
- package/src/dataflow/CLAUDE.md +46 -1
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/backward.js +42 -7
- package/src/dataflow/cross-service-taint.js +83 -1
- package/src/dataflow/demand-query.js +1168 -0
- package/src/dataflow/dep-fingerprint.js +718 -0
- package/src/dataflow/engine.js +48 -6
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ifds-precise.js +22 -0
- package/src/dataflow/incremental-replay.js +118 -0
- package/src/dataflow/incremental-session.js +257 -0
- package/src/dataflow/incremental.js +237 -35
- package/src/dataflow/index.js +25 -94
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/dataflow/summaries.js +72 -6
- package/src/dataflow/tabulation.js +80 -24
- package/src/engine.js +113 -8
- package/src/graph/CLAUDE.md +31 -0
- package/src/graph/adapters/artifact.js +81 -0
- package/src/graph/adapters/crossrepo.js +75 -0
- package/src/graph/adapters/dataflow.js +76 -0
- package/src/graph/adapters/provenance.js +92 -0
- package/src/graph/adapters/semantic.js +89 -0
- package/src/graph/crosswalk-builders.js +152 -0
- package/src/graph/crosswalk.js +146 -0
- package/src/graph/index.js +15 -0
- package/src/graph/observation.js +57 -0
- package/src/graph/refs.js +105 -0
- package/src/graph/registry.js +129 -0
- package/src/graph/relations.js +81 -0
- package/src/graph/scc.js +241 -0
- package/src/graph/view.js +254 -0
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +72 -8
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
- package/src/lineage/boundary-contract.js +273 -0
- package/src/lineage/completion.js +205 -0
- package/src/lineage/continuation.js +140 -0
- package/src/lineage/control-credit.js +313 -0
- package/src/lineage/correlation.js +199 -0
- package/src/lineage/coverage.js +8 -1
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/cut-planner.js +548 -0
- package/src/lineage/driver.js +219 -66
- package/src/lineage/engine.js +166 -21
- package/src/lineage/export-json.js +5 -0
- package/src/lineage/graph-builder.js +84 -7
- package/src/lineage/haskell-view.js +59 -0
- package/src/lineage/index.js +3 -0
- package/src/lineage/nix-view.js +35 -0
- package/src/lineage/owned-build.js +77 -0
- package/src/lineage/owned-worker-entry.js +22 -0
- package/src/lineage/owned-worker.js +95 -0
- package/src/lineage/path-evidence.js +123 -0
- package/src/lineage/path-predicates.js +529 -0
- package/src/lineage/path-query.js +189 -20
- package/src/lineage/path-store.js +191 -17
- package/src/lineage/scc-solver.js +186 -0
- package/src/lineage/smt-orchestrator.js +174 -0
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/lineage/source-seeding.js +36 -2
- package/src/lineage/summaries.js +66 -4
- package/src/lineage/value-versions.js +130 -0
- package/src/lineage/witness-selection.js +95 -0
- package/src/pipeline/scan-health.js +18 -0
- package/src/posture/CLAUDE.md +17 -0
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/evidence-bundle.js +1 -1
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/witness-bundle.js +573 -0
- package/src/posture/witness-runtime.js +276 -0
- package/src/report/index.js +2 -0
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16182
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5817.index.js +0 -714
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- 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
|
+
}
|