@clear-capabilities/agentic-security-scanner 0.185.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 (167) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/agentic-security.js +24 -1
  3. package/dist/1128.index.js +25 -21
  4. package/dist/2584.index.js +1 -1
  5. package/dist/4171.index.js +2 -2
  6. package/dist/5752.index.js +466 -0
  7. package/dist/7405.index.js +1 -1
  8. package/dist/8158.index.js +1713 -0
  9. package/dist/8798.index.js +1 -1
  10. package/dist/9382.index.js +2 -2
  11. package/dist/9579.index.js +1 -1
  12. package/dist/agentic-security.mjs +29 -29
  13. package/dist/agentic-security.mjs.sha256 +1 -1
  14. package/dist/{0b301d45b231f144a1b4.json → ca62ee93079be4c24eee.json} +7 -7
  15. package/package.json +7 -7
  16. package/src/dataflow/CLAUDE.md +6 -3
  17. package/src/dataflow/access-paths.js +12 -2
  18. package/src/dataflow/demand-query.js +279 -40
  19. package/src/dataflow/dep-fingerprint.js +1 -1
  20. package/src/dataflow/exploit-prover.js +6 -2
  21. package/src/dataflow/ir-cache.js +268 -0
  22. package/src/dataflow/privacy-taint.js +7 -0
  23. package/src/engine.js +62 -5
  24. package/src/ir/CLAUDE.md +4 -0
  25. package/src/ir/callgraph.js +36 -8
  26. package/src/ir/index.js +18 -4
  27. package/src/ir/parser-js.js +10 -0
  28. package/src/language/CLAUDE.md +13 -0
  29. package/src/language/nix-option-types.js +170 -0
  30. package/src/language/nix-script-taint.js +46 -3
  31. package/src/language/nix-secrets.js +20 -2
  32. package/src/lineage/CLAUDE.md +2 -1
  33. package/src/lineage/smt-orchestrator.js +47 -11
  34. package/src/lineage/smt-z3-backend.js +294 -0
  35. package/src/posture/CLAUDE.md +5 -1
  36. package/src/posture/business-logic.js +16 -7
  37. package/src/posture/dag-commands.js +442 -0
  38. package/src/posture/finding-defaults.js +19 -0
  39. package/src/posture/git-history.js +91 -1
  40. package/src/sast/CLAUDE.md +2 -0
  41. package/src/sast/_call-args.js +70 -0
  42. package/src/sast/init-scope.js +87 -0
  43. package/src/sast/listen-bind-address.js +49 -0
  44. package/src/sast/redirect-target.js +106 -0
  45. package/src/sast/reflected-input.js +76 -0
  46. package/src/sast/send-file-root.js +27 -0
  47. package/src/sast/sensitive-data-logging.js +38 -1
  48. package/dist/119.index.js +0 -197
  49. package/dist/128.index.js +0 -129
  50. package/dist/1293.index.js +0 -5689
  51. package/dist/1336.index.js +0 -16331
  52. package/dist/1351.index.js +0 -133
  53. package/dist/1387.index.js +0 -271
  54. package/dist/1437.index.js +0 -23
  55. package/dist/1445.index.js +0 -110
  56. package/dist/1505.index.js +0 -151
  57. package/dist/1557.index.js +0 -293
  58. package/dist/1621.index.js +0 -311
  59. package/dist/1751.index.js +0 -160
  60. package/dist/1765.index.js +0 -727
  61. package/dist/1861.index.js +0 -690
  62. package/dist/1906.index.js +0 -433
  63. package/dist/208.index.js +0 -1631
  64. package/dist/2223.index.js +0 -159
  65. package/dist/2238.index.js +0 -295
  66. package/dist/2530.index.js +0 -152
  67. package/dist/2565.index.js +0 -116
  68. package/dist/2574.index.js +0 -127
  69. package/dist/2620.index.js +0 -345
  70. package/dist/2773.index.js +0 -98
  71. package/dist/2775.index.js +0 -32
  72. package/dist/2966.index.js +0 -171
  73. package/dist/2972.index.js +0 -87
  74. package/dist/2991.index.js +0 -131
  75. package/dist/3063.index.js +0 -833
  76. package/dist/3075.index.js +0 -227
  77. package/dist/3121.index.js +0 -297
  78. package/dist/3314.index.js +0 -263
  79. package/dist/3480.index.js +0 -1822
  80. package/dist/3481.index.js +0 -24
  81. package/dist/3506.index.js +0 -268
  82. package/dist/3533.index.js +0 -591
  83. package/dist/3653.index.js +0 -450
  84. package/dist/3740.index.js +0 -722
  85. package/dist/3822.index.js +0 -131
  86. package/dist/3868.index.js +0 -292
  87. package/dist/3918.index.js +0 -192
  88. package/dist/4090.index.js +0 -288
  89. package/dist/4113.index.js +0 -1286
  90. package/dist/4121.index.js +0 -1190
  91. package/dist/4142.index.js +0 -690
  92. package/dist/4168.index.js +0 -509
  93. package/dist/4281.index.js +0 -993
  94. package/dist/4336.index.js +0 -436
  95. package/dist/4387.index.js +0 -532
  96. package/dist/4413.index.js +0 -302
  97. package/dist/4476.index.js +0 -4510
  98. package/dist/4491.index.js +0 -133
  99. package/dist/4538.index.js +0 -185
  100. package/dist/4684.index.js +0 -319
  101. package/dist/4740.index.js +0 -165
  102. package/dist/4780.index.js +0 -209
  103. package/dist/4802.index.js +0 -225
  104. package/dist/4812.index.js +0 -379
  105. package/dist/4835.index.js +0 -126
  106. package/dist/494.index.js +0 -226
  107. package/dist/4991.index.js +0 -549
  108. package/dist/5199.index.js +0 -305
  109. package/dist/5216.index.js +0 -109
  110. package/dist/5439.index.js +0 -301
  111. package/dist/5443.index.js +0 -97
  112. package/dist/5496.index.js +0 -377
  113. package/dist/5738.index.js +0 -240
  114. package/dist/5801.index.js +0 -588
  115. package/dist/5817.index.js +0 -714
  116. package/dist/5858.index.js +0 -223
  117. package/dist/5933.index.js +0 -143
  118. package/dist/601.index.js +0 -23
  119. package/dist/6173.index.js +0 -273
  120. package/dist/6291.index.js +0 -1871
  121. package/dist/6312.index.js +0 -666
  122. package/dist/634.index.js +0 -16182
  123. package/dist/644.index.js +0 -87
  124. package/dist/6508.index.js +0 -283
  125. package/dist/6560.index.js +0 -645
  126. package/dist/6576.index.js +0 -422
  127. package/dist/6579.index.js +0 -271
  128. package/dist/6655.index.js +0 -3221
  129. package/dist/6745.index.js +0 -305
  130. package/dist/6818.index.js +0 -21642
  131. package/dist/6932.index.js +0 -1190
  132. package/dist/6972.index.js +0 -167
  133. package/dist/6995.index.js +0 -163
  134. package/dist/7249.index.js +0 -191
  135. package/dist/7372.index.js +0 -1197
  136. package/dist/7546.index.js +0 -541
  137. package/dist/76.index.js +0 -448
  138. package/dist/7879.index.js +0 -163
  139. package/dist/8025.index.js +0 -130
  140. package/dist/8054.index.js +0 -248
  141. package/dist/808.index.js +0 -1330
  142. package/dist/8097.index.js +0 -201
  143. package/dist/8126.index.js +0 -191
  144. package/dist/8178.index.js +0 -78
  145. package/dist/8382.index.js +0 -93
  146. package/dist/8408.index.js +0 -261
  147. package/dist/8540.index.js +0 -174
  148. package/dist/8635.index.js +0 -802
  149. package/dist/8688.index.js +0 -410
  150. package/dist/8699.index.js +0 -779
  151. package/dist/8891.index.js +0 -457
  152. package/dist/8916.index.js +0 -885
  153. package/dist/9146.index.js +0 -170
  154. package/dist/9168.index.js +0 -841
  155. package/dist/9292.index.js +0 -369
  156. package/dist/9422.index.js +0 -866
  157. package/dist/9440.index.js +0 -453
  158. package/dist/9479.index.js +0 -152
  159. package/dist/9481.index.js +0 -220
  160. package/dist/9539.index.js +0 -100
  161. package/dist/9547.index.js +0 -103
  162. package/dist/9692.index.js +0 -90
  163. package/dist/9800.index.js +0 -462
  164. package/dist/9899.index.js +0 -138
  165. package/dist/9981.index.js +0 -206
  166. package/dist/999.index.js +0 -117
  167. package/dist/9999.index.js +0 -1698
@@ -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
+ }
@@ -1030,7 +1030,11 @@ A sibling of `evidence-bundle.js` for one source-to-sink PATH rather than one fi
1030
1030
  - **Proof binding.** An external proof reference must name this bundle's sink node, data element, graph digest and configuration digest; a stale or mismatched one is listed under `exploit.rejected` with reasons and is never re-attached.
1031
1031
  - **Partial stays partial.** Completion fields are required (a missing one is invalid, never "complete"), the bundle is JSON-normalized at build so serialization cannot change it, and replay re-derives the path terminal, the truncation marks and `overall`.
1032
1032
  - **Redaction.** Identities become `idt_` tokens (`lineage/identity-token.js`), trace/session ids become `sref_` references, free text goes through `mcp/redact.js`, unknown input fields are dropped and only counted.
1033
- - **Limits.** Building needs the hop stream, which a scan does not persist; there is no `attest --witness` yet. Source files are bound only when the caller supplies their text (the rest are listed `unboundSourceFiles` and replay warns). The graph-edge to provenance-edge relation is the flow binding (`graphBinding.flowId`), checked for existence, not re-derived. Cross-service replay depends on DAG-08.
1033
+ - **Limits.** Building needs the hop stream, which a scan does not persist; `witness build` (`dag-commands.js`) therefore re-runs the field analysis rather than reading a stored stream. Source files are bound only when the caller supplies their text (the rest are listed `unboundSourceFiles` and replay warns). The graph-edge to provenance-edge relation is the flow binding (`graphBinding.flowId`), checked for existence, not re-derived. Cross-service replay depends on DAG-08.
1034
+
1035
+ ## Opt-in DAG commands: `dag-commands.js` (DAG-01, 02, 06, 08, 09, 10)
1036
+
1037
+ CLI entry points for capabilities that were library-only: `dataflow view` (typed graph view plus SCC condensation), `dataflow contracts` (boundary contracts via `runBoundaryTaint`), `dataflow sink-query` (demand reverse taint), the completion contract of `dataflow plan-cuts` (`planContract`; the command body is still in `bin/agentic-security.js`), and `witness build|verify`. Dispatched lazily from `bin/agentic-security.js`, so a default scan never imports it and its output is unchanged. Every report carries a `completion` record in the shared vocabulary and `negativeClaimAllowed` is false unless the engine's own envelope earned it. Exit codes: 0 report produced, 1 nothing usable or verification failed, 2 usage. Suite: `test/cli/dag-commands.test.js` (in `test:lineage`). User docs: `docs/security-graph/graph-precision.md` (Commands section).
1034
1038
 
1035
1039
  ## Gotchas
1036
1040
 
@@ -154,19 +154,28 @@ function collectStateSets(fileContents) {
154
154
  }
155
155
 
156
156
  function extractStateMachine(fileContents) {
157
- // Then look for direct writes like `.status = "<not in set>"` and flag.
157
+ // Then look for direct writes like `.status = "<not in set>"` and flag. A write is
158
+ // compared only against the status sets declared in the SAME file: a set in one
159
+ // module says nothing about what another module's `.status` may hold, and matching
160
+ // every `.status =` in the repository against the first set found anywhere flags
161
+ // unrelated objects. Test files are skipped (they assign arbitrary states on purpose).
158
162
  const stateSets = collectStateSets(fileContents);
159
163
  if (!stateSets.length) return [];
164
+ const byFile = new Map();
165
+ for (const s of stateSets) {
166
+ if (!byFile.has(s.file)) byFile.set(s.file, []);
167
+ byFile.get(s.file).push(s);
168
+ }
160
169
  const findings = [];
161
- for (const [fp, c] of Object.entries(fileContents || {})) {
162
- if (typeof c !== 'string' || !JS_TS_RE.test(fp)) continue;
170
+ for (const [fp, sets] of byFile) {
171
+ const c = fileContents[fp];
172
+ if (typeof c !== 'string' || /(^|\/)(test|tests|__tests__|fixtures?)\//i.test(fp) || /\.(test|spec)\.[cm]?[jt]sx?$/i.test(fp)) continue;
163
173
  const writeRe = /\.\s*(?:status|state|phase)\s*=\s*['"]([^'"]+)['"]/g;
164
174
  let m;
165
175
  while ((m = writeRe.exec(c))) {
166
176
  const value = m[1];
167
- const validSet = stateSets.find(s => s.values.size > 0);
168
- if (!validSet) continue;
169
- if (validSet.values.has(value)) continue;
177
+ if (sets.some(s => s.values.has(value))) continue;
178
+ const validSet = sets[0];
170
179
  const line = _lineOf(c, m.index);
171
180
  findings.push({
172
181
  id: `state-machine:${fp}:${line}:${value}`,
@@ -179,7 +188,7 @@ function extractStateMachine(fileContents) {
179
188
  parser: 'STATE-MACHINE',
180
189
  confidence: 0.5,
181
190
  snippet: m[0].slice(0, 200),
182
- remediation: `The statuses recognized by your system appear to be {${[...validSet.values].join(', ')}}. This write sets a different value ('${value}'). If the new value is legitimate, add it to the set; otherwise treat the write as a state-machine bypass and reject it.`,
191
+ remediation: `The statuses recognized by this file appear to be {${[...validSet.values].join(', ')}}. This write sets a different value ('${value}'). If the new value is legitimate, add it to the set; otherwise treat the write as a state-machine bypass and reject it.`,
183
192
  });
184
193
  }
185
194
  }