@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
@@ -125,6 +125,11 @@ function seedPathFor(expr, parentOf) {
125
125
  // from seed records, must not surface a literal `*` name to a user
126
126
  // without translating it into an honest "dynamic key" disclosure.
127
127
 
128
+ // DAG-07 R07.5: generous defaults so an ordinary project never reaches them;
129
+ // they exist so a pathological input cannot make the planner unbounded.
130
+ export const DEFAULT_MAX_SEEDS = 1000000;
131
+ export const DEFAULT_MAX_NODES = 20000000;
132
+
128
133
  // ── §3.3/§3.4: planSeeds ────────────────────────────────────────────────────
129
134
 
130
135
  /**
@@ -144,11 +149,29 @@ function seedPathFor(expr, parentOf) {
144
149
  * A matched expression with NO access path (`accessPathOf` → `null`) is
145
150
  * recorded in `unseedable[]` — never silently dropped (§3.3).
146
151
  */
147
- export function planSeeds(callGraph, { repository } = {}) {
152
+ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, deadline } = {}) {
148
153
  const seeds = [];
149
154
  const unseedable = [];
155
+ // DAG-07 R07.1: a completion record for the seeding step. A function the
156
+ // planner did not reach has no seeds, which looks identical to "this
157
+ // function reads no sources"; the record is what tells them apart.
158
+ const seedCap = Number.isInteger(maxSeeds) && maxSeeds >= 0 ? maxSeeds : DEFAULT_MAX_SEEDS;
159
+ const nodeCap = Number.isInteger(maxNodes) && maxNodes >= 0 ? maxNodes : DEFAULT_MAX_NODES;
160
+ let functionsTotal = 0;
161
+ let functionsScanned = 0;
162
+ let nodesVisited = 0;
163
+ let stopReason = null;
164
+ const shouldStop = () => !!(signal && signal.aborted) || (Number.isFinite(deadline) && Date.now() > deadline);
150
165
  for (const fn of callGraph.functions.values()) {
166
+ functionsTotal += 1;
167
+ if (stopReason) continue;
168
+ if (shouldStop()) { stopReason = 'cancelled'; continue; }
169
+ if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; continue; }
170
+ if (nodesVisited >= nodeCap) { stopReason = 'seeding-limit'; continue; }
171
+ functionsScanned += 1;
151
172
  for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
173
+ nodesVisited += 1;
174
+ if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; break; }
152
175
  const parentOf = new Map();
153
176
  for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
154
177
  for (const root of exprRoots(node)) {
@@ -182,7 +205,18 @@ export function planSeeds(callGraph, { repository } = {}) {
182
205
  }
183
206
  }
184
207
  }
185
- return { seeds, unseedable };
208
+ const notScanned = functionsTotal - functionsScanned;
209
+ const completion = {
210
+ status: stopReason === 'cancelled' ? 'cancelled' : stopReason ? 'partial' : 'complete',
211
+ reasons: stopReason ? [stopReason] : [],
212
+ functionsTotal,
213
+ functionsScanned,
214
+ functionsNotScanned: notScanned,
215
+ seeds: seeds.length,
216
+ unseedable: unseedable.length,
217
+ limits: { maxSeeds: seedCap, maxNodes: nodeCap, nodesVisited },
218
+ };
219
+ return { seeds, unseedable, completion };
186
220
  }
187
221
 
188
222
  // ── §3.5: seedEntryStateFactory — the driver.js opts.seedEntryState hook ──
@@ -1,5 +1,8 @@
1
1
  import { hashState, emptyState, addIdentity } from './field-identity.js';
2
- import { resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity } from './engine.js';
2
+ import {
3
+ resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity,
4
+ argumentContext, callInvocationId, calleeRecorder,
5
+ } from './engine.js';
3
6
  import { accessPathOf } from '../dataflow/access-paths.js';
4
7
  import { functionRecord } from '../ir/callgraph.js';
5
8
 
@@ -51,6 +54,13 @@ export class FieldIdentitySummaryCache {
51
54
  this._stack = new Set();
52
55
  this._contextsByQid = new Map();
53
56
  this._maxContextsPerFn = maxContextsPerFn;
57
+ // DAG-07 R07.3: degradations and unfinished refinements, recorded so a
58
+ // caller can tell a precise result from one that fell back. Append-only.
59
+ this._notes = [];
60
+ }
61
+
62
+ completionNotes() {
63
+ return this._notes.slice();
54
64
  }
55
65
 
56
66
  _key(qid, entryState) {
@@ -74,6 +84,21 @@ export class FieldIdentitySummaryCache {
74
84
  }
75
85
 
76
86
  compute(qid, entryState, analyzeFn) {
87
+ // While `qid` is marked by `refreshing()` (SCC solver only, driver.js): re-run `analyzeFn` for an
88
+ // entry that is ALREADY seeded in the cache, without the recursion-stub
89
+ // stack and without the bounded refinement loop. A self-call or a call
90
+ // to another member of the same component inside `analyzeFn` therefore
91
+ // reads the previous round's cached summary instead of a bottom stub.
92
+ // Never marked => behavior is byte-identical to before. The mark lives on
93
+ // the cache (not an argument) so wrappers around compute() stay valid.
94
+ if (this._refreshing && this._refreshing.get(qid) === hashState(entryState)) {
95
+ // Consume the mark: a nested call to the same context (a self-call)
96
+ // must take the normal path and hit the cached previous-round summary.
97
+ this._refreshing.delete(qid);
98
+ const refreshed = analyzeFn(entryState);
99
+ this.set(qid, entryState, refreshed);
100
+ return refreshed;
101
+ }
77
102
  // Ordering matches dataflow/summaries.js's own compute() exactly:
78
103
  // cache-hit checked FIRST, before the `_stack`-based recursion guard.
79
104
  // This is load-bearing, not incidental — B5's whole point is that a
@@ -130,6 +155,7 @@ export class FieldIdentitySummaryCache {
130
155
  // every later reader of the empty-entry context too.
131
156
  const base = this._cache.get(this._key(qid, emptyState())) ?? emptyFieldSummary();
132
157
  const fallback = { ...base, degradedReason: 'context-cap' };
158
+ this._notes.push({ qid, reason: 'context-cap' });
133
159
  this.set(qid, entryState, fallback);
134
160
  return fallback;
135
161
  }
@@ -219,12 +245,16 @@ export class FieldIdentitySummaryCache {
219
245
  // round) mirrors dataflow/summaries.js's own compute() exactly.
220
246
  if (this._hitRecursion) {
221
247
  const FP_MAX = 3;
248
+ let converged = false;
222
249
  for (let fp = 0; fp < FP_MAX; fp++) {
223
250
  const prev = summary;
224
251
  summary = analyzeFn(entryState);
225
- if (fieldSummaryEq(prev, summary)) break;
252
+ if (fieldSummaryEq(prev, summary)) { converged = true; break; }
226
253
  this.set(qid, entryState, summary);
227
254
  }
255
+ // R07.3/R01.5: still changing after the last round means the
256
+ // summary is an under-approximation, not a fixed point.
257
+ if (!converged) this._notes.push({ qid, reason: 'recursion-nonconverged', rounds: FP_MAX });
228
258
  }
229
259
  // Defensive strip, mirroring dataflow's own equivalent: analyzeFn's
230
260
  // real return shape here never actually carries `_recursive` under
@@ -238,6 +268,28 @@ export class FieldIdentitySummaryCache {
238
268
  }
239
269
  }
240
270
 
271
+ // Marks `qid` as being refreshed by the SCC solver for the duration of `fn`.
272
+ // Only the exact `entryState` context is refreshed; any other context of
273
+ // `qid` reached meanwhile (e.g. a call-site argument state) takes the normal
274
+ // guarded path, so recursion through it still terminates.
275
+ refreshing(qid, entryState, fn) {
276
+ (this._refreshing ||= new Map()).set(qid, hashState(entryState));
277
+ try { return fn(); } finally { this._refreshing.delete(qid); }
278
+ }
279
+
280
+ // Forgets every cached context of `qid` except those whose state hash is in
281
+ // `keepHashes` (a Set). Used by the SCC solver between rounds so a context
282
+ // computed against an earlier round's summaries is never reused.
283
+ dropContexts(qid, keepHashes = new Set()) {
284
+ const seen = this._contextsByQid.get(qid);
285
+ if (!seen) return;
286
+ for (const h of [...seen]) {
287
+ if (keepHashes.has(h)) continue;
288
+ this._cache.delete(`${qid}::${h}`);
289
+ seen.delete(h);
290
+ }
291
+ }
292
+
241
293
  size() {
242
294
  return this._cache.size;
243
295
  }
@@ -326,11 +378,11 @@ export function entryStateFromCall(paramNames, callArgs, callerState, ctx) {
326
378
  // call site) is load-bearing, not stylistic — it means no future second
327
379
  // caller of entryStateFromCall can reintroduce the hazard by passing the
328
380
  // full ctx through a different path.
329
- const argCtx = ctx?.recordHop ? { recordHop: ctx.recordHop } : undefined;
330
381
  let entryState = emptyState();
331
382
  const n = Math.min(paramNames.length, callArgs.length);
332
383
  for (let i = 0; i < n; i++) {
333
384
  const paramName = paramNames[i];
385
+ const argCtx = argumentContext(ctx, i);
334
386
  const resolved = resolveExprIdentities(callerState, callArgs[i], argCtx);
335
387
  const residual = residualFlat(resolved.flat, resolved.byPath);
336
388
  for (const id of residual) entryState = addIdentity(entryState, paramName, id);
@@ -409,6 +461,12 @@ export function createCallSummaryResolver(cache, lookupCallee) {
409
461
  fromPath: null, toPath: path, dataElementId: id,
410
462
  syntacticPath: null, widenReason: null, lossReason: null,
411
463
  peerScope: qid, peerContext: calleeContext,
464
+ ...(ctx.correlationVersion === 2 ? {
465
+ callRole: 'bind', invocationId: callInvocationId(ctx),
466
+ formalPosition: fn.params.indexOf(path.split('.')[0]),
467
+ relPath: path.includes('.') ? path.slice(path.indexOf('.') + 1) : '',
468
+ toVersion: 'entry',
469
+ } : null),
412
470
  });
413
471
  }
414
472
  }
@@ -444,7 +502,11 @@ export function createCallSummaryResolver(cache, lookupCallee) {
444
502
  // deepest call always wins). Passing a `context` field here would be
445
503
  // silently overwritten and is dead code.
446
504
  const calleeCtx = ctx?.recordHop
447
- ? { resolveCallSummary, recordHop: ctx.recordHop }
505
+ ? {
506
+ resolveCallSummary,
507
+ recordHop: ctx.correlationVersion === 2 ? calleeRecorder(ctx) : ctx.recordHop,
508
+ ...(ctx.correlationVersion === 2 ? { correlationVersion: 2 } : null),
509
+ }
448
510
  : { resolveCallSummary };
449
511
  const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
450
512
  return summaryFromAnalysisResult(result);
@@ -0,0 +1,130 @@
1
+ //
2
+ // value-versions.js: SSA-like definition identities for the lineage engine
3
+ // (DAG-03, R03.3).
4
+ //
5
+ // A reaching-definitions pass over one function's CFG that answers: "which
6
+ // definitions of this state path can reach the entry of this CFG node?" The
7
+ // answer is a SET, so a merge keeps both alternatives (a phi) instead of
8
+ // pretending they co-occur or picking one, and a later strong write kills the
9
+ // earlier definition of that path and everything under it, whether or not
10
+ // the written value carried any identity (a clean literal is still a kill).
11
+ //
12
+ // Definition ids are the CFG node ids of the writing `assign`, plus the
13
+ // reserved id 'entry' for a value that was live on function entry (a
14
+ // parameter, a seeded source, anything no assignment has overwritten).
15
+ //
16
+ // The pass is purely structural. It does not read or change the field
17
+ // identity state, so it cannot alter an analysis result; it only labels the
18
+ // provenance halves the engine emits when correlation version 2 is on.
19
+ //
20
+ export const ENTRY_VERSION = 'entry';
21
+
22
+ function nodeEntries(nodes) {
23
+ if (nodes instanceof Map) return [...nodes.entries()];
24
+ return Object.entries(nodes ?? {});
25
+ }
26
+
27
+ function pathHasWildcard(path) {
28
+ return path.split('.').includes('*');
29
+ }
30
+
31
+ function definitePrefix(path) {
32
+ const segs = path.split('.');
33
+ const idx = segs.indexOf('*');
34
+ if (idx <= 0) return null;
35
+ return segs.slice(0, idx).join('.');
36
+ }
37
+
38
+ /** The definitions recorded for the longest self-or-ancestor key of `path` in `st`, or {entry}. */
39
+ function lookup(st, path) {
40
+ let p = path;
41
+ for (;;) {
42
+ const hit = st.get(p);
43
+ if (hit) return hit;
44
+ const i = p.lastIndexOf('.');
45
+ if (i === -1) return null;
46
+ p = p.slice(0, i);
47
+ }
48
+ }
49
+
50
+ function join(a, b) {
51
+ const out = new Map();
52
+ const keys = new Set([...a.keys(), ...b.keys()]);
53
+ for (const k of keys) {
54
+ const sa = a.get(k) ?? lookup(a, k) ?? new Set([ENTRY_VERSION]);
55
+ const sb = b.get(k) ?? lookup(b, k) ?? new Set([ENTRY_VERSION]);
56
+ out.set(k, new Set([...sa, ...sb]));
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function equal(a, b) {
62
+ if (a.size !== b.size) return false;
63
+ for (const [k, sa] of a) {
64
+ const sb = b.get(k);
65
+ if (!sb || sb.size !== sa.size) return false;
66
+ for (const d of sa) if (!sb.has(d)) return false;
67
+ }
68
+ return true;
69
+ }
70
+
71
+ function transfer(nid, node, st) {
72
+ if (node.kind !== 'assign' || typeof node.target !== 'string') return st;
73
+ const out = new Map(st);
74
+ if (pathHasWildcard(node.target)) {
75
+ // A computed-key write is a WEAK update: it adds a definition to the
76
+ // container and kills nothing.
77
+ const container = definitePrefix(node.target);
78
+ if (container) {
79
+ const prior = out.get(container) ?? lookup(st, container) ?? new Set([ENTRY_VERSION]);
80
+ out.set(container, new Set([...prior, nid]));
81
+ }
82
+ return out;
83
+ }
84
+ const t = node.target;
85
+ for (const k of [...out.keys()]) {
86
+ if (k === t || k.startsWith(`${t}.`)) out.delete(k);
87
+ }
88
+ out.set(t, new Set([nid]));
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * @param {{cfg:{entry:string, nodes:Map|object}, params?:string[]}} fn
94
+ * @returns {{versionsAt:(nodeId:string, path:string)=>string[]}}
95
+ */
96
+ export function computeReachingDefinitions(fn) {
97
+ const nodes = new Map(nodeEntries(fn.cfg?.nodes));
98
+ const inStates = new Map();
99
+ const entry = new Map();
100
+ for (const p of fn.params ?? []) entry.set(p, new Set([ENTRY_VERSION]));
101
+ inStates.set(fn.cfg.entry, entry);
102
+ const work = [fn.cfg.entry];
103
+ const outStates = new Map();
104
+ let guard = 0;
105
+ const cap = Math.max(10000, nodes.size * 64);
106
+ while (work.length && guard++ < cap) {
107
+ const nid = work.shift();
108
+ const node = nodes.get(nid);
109
+ if (!node) continue;
110
+ const out = transfer(nid, node, inStates.get(nid) ?? new Map());
111
+ const prev = outStates.get(nid);
112
+ if (prev && equal(prev, out)) continue;
113
+ outStates.set(nid, out);
114
+ for (const s of node.succ ?? []) {
115
+ const pi = inStates.get(s);
116
+ const ni = pi ? join(pi, out) : out;
117
+ if (!pi || !equal(pi, ni)) {
118
+ inStates.set(s, ni);
119
+ work.push(s);
120
+ }
121
+ }
122
+ }
123
+ return {
124
+ versionsAt(nodeId, path) {
125
+ const st = inStates.get(nodeId);
126
+ const hit = st ? lookup(st, path) : null;
127
+ return hit ? [...hit].sort() : [ENTRY_VERSION];
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,95 @@
1
+ // witness-selection.js: which paths to DISPLAY, and what may be claimed from
2
+ // them (DAG-07 R07.8).
3
+ //
4
+ // Two jobs that must never be confused:
5
+ // 1. rankWitnesses: choose a small, diverse sample of the enumerated paths
6
+ // to show. Diversity is over source, sink, data class, trust boundary,
7
+ // control state and ambiguity. This is presentation only.
8
+ // 2. assessAllPathsProtected: decide whether "every path is protected" may
9
+ // be said. It looks at EVERY enumerated path, displayed or not, and it
10
+ // refuses outright when the enumeration itself was truncated, so a
11
+ // sample that happens to contain only protected paths can never produce
12
+ // the claim. A hidden unprotected path refutes the claim.
13
+ //
14
+ // Pure: no store access, no engine imports.
15
+
16
+ export const DIVERSITY_DIMENSIONS = Object.freeze(['source', 'sink', 'dataClass', 'trustBoundary', 'controlState', 'ambiguity']);
17
+
18
+ /**
19
+ * Attribute values for one path. `attrsOf(path)` may supply `dataClass` and
20
+ * `controlState` ('protected' | 'unprotected' | 'unknown'); source, sink,
21
+ * boundary and ambiguity come from the path itself.
22
+ */
23
+ export function witnessAttributes(path, attrsOf) {
24
+ const extra = typeof attrsOf === 'function' ? (attrsOf(path) ?? {}) : {};
25
+ return {
26
+ source: path.sourceNodeId ?? null,
27
+ sink: path.sinkNodeId ?? null,
28
+ dataClass: extra.dataClass ?? path.dataElementId ?? 'unknown',
29
+ trustBoundary: (path.crossScopeCount ?? 0) > 0 ? 'crosses' : 'local',
30
+ controlState: extra.controlState ?? 'unknown',
31
+ ambiguity: (path.ambiguousHopCount ?? 0) > 0 ? 'ambiguous' : 'correlated',
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Greedy diversity ranking. Each step picks the path adding the most
37
+ * not-yet-shown values across the six dimensions; ties go to the path id so
38
+ * the order is deterministic. Never drops or alters a path: `ranked` is a
39
+ * permutation of the input and `displayed` is its prefix.
40
+ */
41
+ export function rankWitnesses(paths, { limit = 10, attrsOf } = {}) {
42
+ const list = Array.isArray(paths) ? paths : [];
43
+ const cap = Number.isInteger(limit) && limit >= 0 ? limit : 10;
44
+ const seen = Object.fromEntries(DIVERSITY_DIMENSIONS.map((d) => [d, new Set()]));
45
+ const pool = list.map((p) => ({ p, a: witnessAttributes(p, attrsOf) }));
46
+ const ranked = [];
47
+ while (pool.length) {
48
+ let best = -1;
49
+ let bestGain = -1;
50
+ for (let i = 0; i < pool.length; i++) {
51
+ let gain = 0;
52
+ for (const d of DIVERSITY_DIMENSIONS) if (!seen[d].has(pool[i].a[d])) gain += 1;
53
+ if (gain > bestGain || (gain === bestGain && String(pool[i].p.id) < String(pool[best].p.id))) { best = i; bestGain = gain; }
54
+ }
55
+ const [chosen] = pool.splice(best, 1);
56
+ for (const d of DIVERSITY_DIMENSIONS) seen[d].add(chosen.a[d]);
57
+ ranked.push(chosen.p);
58
+ }
59
+ const displayed = ranked.slice(0, cap);
60
+ const coverage = Object.fromEntries(DIVERSITY_DIMENSIONS.map((d) => [d, new Set(displayed.map((p) => witnessAttributes(p, attrsOf)[d])).size]));
61
+ return { ranked, displayed, hiddenCount: ranked.length - displayed.length, coverage };
62
+ }
63
+
64
+ /**
65
+ * May "all paths are protected" be asserted?
66
+ *
67
+ * `enumerated` is every path the query produced (before any display cap);
68
+ * `result` is the reconstructPaths result they came from. The claim is
69
+ * allowed only when the query was exhaustive (status complete, nothing
70
+ * dropped or truncated, at least one path) and every path is protected.
71
+ * `holds` is false when ANY enumerated path, hidden or not, is unprotected
72
+ * (a refutation needs no exhaustiveness), and null when it is merely not
73
+ * established.
74
+ */
75
+ export function assessAllPathsProtected({ result, enumerated, attrsOf }) {
76
+ const paths = Array.isArray(enumerated) ? enumerated : [];
77
+ const stateOf = (p) => (typeof attrsOf === 'function' ? (attrsOf(p)?.controlState ?? 'unknown') : 'unknown');
78
+ const unprotected = paths.filter((p) => stateOf(p) === 'unprotected');
79
+ const unknown = paths.filter((p) => stateOf(p) !== 'protected' && stateOf(p) !== 'unprotected');
80
+ const reasons = [];
81
+ if (!result || result.status !== 'complete') reasons.push('query-not-complete');
82
+ if (result && (result.truncated || result.droppedPathCount > 0 || result.analysisTruncated)) reasons.push('truncated-sample');
83
+ if (result && result.resumed) reasons.push('resumed-page');
84
+ if (paths.length === 0) reasons.push('no-paths');
85
+ if (unknown.length) reasons.push('control-state-unknown');
86
+ if (unprotected.length) {
87
+ return {
88
+ claim: 'all-paths-protected', allowed: false, holds: false,
89
+ reasons: ['refuted-by-unprotected-path'],
90
+ refutingPathId: unprotected[0].id ?? null, unprotectedCount: unprotected.length,
91
+ };
92
+ }
93
+ const allowed = reasons.length === 0;
94
+ return { claim: 'all-paths-protected', allowed, holds: allowed ? true : null, reasons, unprotectedCount: 0 };
95
+ }
@@ -84,6 +84,19 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
84
84
  if (lineageStatus?.failure) {
85
85
  conditions.push(`lineage graph build threw and was skipped: ${lineageStatus.failure}`);
86
86
  }
87
+ if (Array.isArray(lineageStatus?.incomplete) && lineageStatus.incomplete.length) {
88
+ conditions.push(`lineage field analysis stopped early in ${lineageStatus.incomplete.length} function(s) (iteration budget); a missing flow from those functions is not evidence of safety`);
89
+ }
90
+ if (lineageStatus?.completion && lineageStatus.completion.status !== 'complete') {
91
+ // DAG-07 R07.2: construction limits, cancellation, seeding limits and a
92
+ // zero-flow result from incomplete analysis all land here, in terms of
93
+ // the shared envelope, so none of them can be silent.
94
+ const c = lineageStatus.completion;
95
+ conditions.push(`lineage analysis is ${c.status} (${(c.reasons || []).join(', ') || 'no reason recorded'}); absence of a flow is not evidence that none exists`);
96
+ }
97
+ if (Array.isArray(lineageStatus?.degraded) && lineageStatus.degraded.length) {
98
+ conditions.push(`lineage analysis used a coarser summary for ${lineageStatus.degraded.length} call context(s) (context cap or unconverged recursion)`);
99
+ }
87
100
  // FR-203: a detector that threw on at least one file (captured via
88
101
  // FR-201's runDetector isolation) is a real analysis gap distinct from
89
102
  // an ANNOTATOR error above — annotators run post-detection over the
@@ -136,6 +149,8 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
136
149
  reason: deepStatus.reason ?? null,
137
150
  failure: deepStatus.failure ?? null,
138
151
  ...(Array.isArray(deepStatus.incomplete) && deepStatus.incomplete.length ? { incomplete: deepStatus.incomplete } : {}),
152
+ // DAG-05 R05.7: present only when the cross-scan cache was requested; says what was reused, what was recomputed, and why a run was cold.
153
+ ...(deepStatus.incremental ? { incremental: deepStatus.incremental } : {}),
139
154
  }
140
155
  : null,
141
156
  lineageAnalysis: lineageStatus
@@ -144,6 +159,9 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
144
159
  enabled: !!lineageStatus.enabled,
145
160
  reason: lineageStatus.reason ?? null,
146
161
  failure: lineageStatus.failure ?? null,
162
+ ...(lineageStatus.completion ? { completion: lineageStatus.completion } : {}),
163
+ ...(Array.isArray(lineageStatus.incomplete) && lineageStatus.incomplete.length ? { incomplete: lineageStatus.incomplete } : {}),
164
+ ...(Array.isArray(lineageStatus.degraded) && lineageStatus.degraded.length ? { degraded: lineageStatus.degraded } : {}),
147
165
  }
148
166
  : null,
149
167
  annotatorErrorCount: safeAnnotatorErrors.length,
@@ -1019,6 +1019,23 @@ Measures a deterministic baseline and model arms, and routes tasks from the meas
1019
1019
  * **The router is the policy layer, `routing/decide.js` is the calibrated one.** `policy-router.js` selects from measured scoreboard entries (quality, median cost, median latency, run count) under four policies; `decide.js` keeps quality LOWER bounds on held-out outcomes per stratum. They do not call each other. The router refuses to guess: too few runs, unknown cost under a cost policy, an egress denial or a dataset mismatch excludes a model with the reason, and `threat-model` is blocked because it has no evaluation task.
1020
1020
  * **Digest-checked state.** Runs and the scoreboard live under `.agentic-security/eval-lab/` (registered in `artifact-registry.js`); a record that no longer matches its digest is reported and never used for routing.
1021
1021
 
1022
+ ## Replayable path witnesses and scoped runtime support: `witness-bundle.js`, `witness-runtime.js` (DAG-10)
1023
+
1024
+ A sibling of `evidence-bundle.js` for one source-to-sink PATH rather than one finding. Same Ed25519 key, same self-issued trust basis (`buildLocalIssuance`, newly exported, is the only edit to `evidence-bundle.js`). Pure of the scan path: nothing here is wired into `engine.js`; the one production caller is `verify-attestation` in `bin/agentic-security.js`. Suite: `test/witness-bundle.test.js` (in `test:posture`).
1025
+
1026
+ - **Artifacts, not trust.** `exportProvenanceArtifact({hops, incomplete})` is the provenance store export: the normalized 14-field hop stream plus the truncation marks that never travel as hops (`analysisCompletion.incomplete`). `buildWitnessBundle` records graph digest (`computeGraphDigest`), provenance digest, source blob sha256s, the analysis configuration digest, transfer-model fingerprints, ordered edges (full derivation fields plus call/return binding), sink/source/field/context references and completion records. `replayWitness(bundle, {graph, provenance, sources, config})` is offline and bounded (`WITNESS_BOUNDS`): it rebuilds a `PathStore` from the hop stream and requires every recorded edge to be the edge the store derives, the edges to chain, each call/return binding to match both the store and a carrying hop, the terminal reason and truncation state to re-derive, and every digest to match.
1027
+ - **A valid signature proves integrity only.** The bundle carries `proves`/`doesNotProve` inside the signed bytes and the verifier rejects a bundle that drops or edits them. Transfer-model fingerprints are computed by RUNNING the half-edge classifiers on fixed probe hops (a minified bundle and the source tree agree); replay under a different fingerprint fails rather than pretending the old model still holds. Replay does not validate the model.
1028
+ - **Four statuses, never merged** (`witness-runtime.js`): `modeledPath`, `feasibility` (the nullable DAG-04 `controlEvidence` slot; `not_assessed` until it exists), `runtimeSupport`, `exploitProof`. `evidence` holds exactly these four keys, each `{status}`; any other key (a score, a confidence) fails replay.
1029
+ - **Runtime support rules.** `path_supported` needs ONE causal group covering every path edge with compatible environment, deployment, time window, identity scope, order and unambiguous mappings; edges seen in unrelated traces are `edge_partial`. Low-confidence mappings stay `candidate_only`; `no_observation_in_window` and `not_consulted` are different and neither is evidence of safety; a denied observation is `blocked_attempt` and proves only that attempt. The closed-world `RuntimeObservation` contract has no trace or session field, so causal groups are caller-supplied: from the observation store alone the strongest answer is `edge_partial`.
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
+ - **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
+ - **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; `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).
1038
+
1022
1039
  ## Gotchas
1023
1040
 
1024
1041
  - The seed `calibration-seed.json` is small (n < 30 for several families). Don't treat it as a held-out set — that's `holdout-eval.js`'s job, against an externally-supplied JSONL.
@@ -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
  }