@clear-capabilities/agentic-security-scanner 0.183.1 → 0.185.0

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 (85) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/bin/agentic-security.js +88 -0
  3. package/dist/{2a8456e0800aeee871b6.json → 0b301d45b231f144a1b4.json} +27 -9
  4. package/dist/1128.index.js +908 -0
  5. package/dist/1336.index.js +261 -112
  6. package/dist/1665.index.js +828 -0
  7. package/dist/2923.index.js +17 -9
  8. package/dist/9382.index.js +23 -2
  9. package/dist/9579.index.js +11 -4
  10. package/dist/agentic-security.mjs +15 -15
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/dist/frontend/src/shell.js +12 -2
  13. package/package.json +27 -9
  14. package/src/context-intelligence/controls.js +15 -0
  15. package/src/context-intelligence/reachability.js +8 -2
  16. package/src/dataflow/CLAUDE.md +43 -1
  17. package/src/dataflow/backward.js +42 -7
  18. package/src/dataflow/cross-service-taint.js +83 -1
  19. package/src/dataflow/demand-query.js +929 -0
  20. package/src/dataflow/dep-fingerprint.js +718 -0
  21. package/src/dataflow/engine.js +48 -6
  22. package/src/dataflow/ifds-precise.js +22 -0
  23. package/src/dataflow/incremental-replay.js +118 -0
  24. package/src/dataflow/incremental-session.js +257 -0
  25. package/src/dataflow/incremental.js +237 -35
  26. package/src/dataflow/index.js +25 -94
  27. package/src/dataflow/summaries.js +72 -6
  28. package/src/dataflow/tabulation.js +80 -24
  29. package/src/discovery/hunter.js +6 -2
  30. package/src/discovery/index.js +5 -2
  31. package/src/engine.js +63 -3
  32. package/src/graph/CLAUDE.md +31 -0
  33. package/src/graph/adapters/artifact.js +81 -0
  34. package/src/graph/adapters/crossrepo.js +75 -0
  35. package/src/graph/adapters/dataflow.js +76 -0
  36. package/src/graph/adapters/provenance.js +92 -0
  37. package/src/graph/adapters/semantic.js +89 -0
  38. package/src/graph/crosswalk-builders.js +152 -0
  39. package/src/graph/crosswalk.js +146 -0
  40. package/src/graph/index.js +15 -0
  41. package/src/graph/observation.js +57 -0
  42. package/src/graph/refs.js +105 -0
  43. package/src/graph/registry.js +129 -0
  44. package/src/graph/relations.js +81 -0
  45. package/src/graph/scc.js +241 -0
  46. package/src/graph/view.js +254 -0
  47. package/src/language/haskell-disclosure.js +13 -1
  48. package/src/lineage/CLAUDE.md +71 -8
  49. package/src/lineage/DESIGN_GRAPH_BUILDER.md +1 -1
  50. package/src/lineage/DESIGN_PATH_PROVENANCE.md +6 -0
  51. package/src/lineage/boundary-contract.js +273 -0
  52. package/src/lineage/completion.js +205 -0
  53. package/src/lineage/continuation.js +140 -0
  54. package/src/lineage/control-credit.js +313 -0
  55. package/src/lineage/correlation.js +199 -0
  56. package/src/lineage/coverage.js +8 -1
  57. package/src/lineage/cross-repo-link.js +22 -0
  58. package/src/lineage/cut-planner.js +548 -0
  59. package/src/lineage/driver.js +219 -66
  60. package/src/lineage/engine.js +166 -21
  61. package/src/lineage/export-json.js +5 -0
  62. package/src/lineage/graph-builder.js +84 -7
  63. package/src/lineage/haskell-view.js +59 -0
  64. package/src/lineage/index.js +3 -0
  65. package/src/lineage/nix-view.js +35 -0
  66. package/src/lineage/observation-adapters.js +17 -9
  67. package/src/lineage/owned-build.js +77 -0
  68. package/src/lineage/owned-worker-entry.js +22 -0
  69. package/src/lineage/owned-worker.js +95 -0
  70. package/src/lineage/path-evidence.js +123 -0
  71. package/src/lineage/path-predicates.js +529 -0
  72. package/src/lineage/path-query.js +189 -20
  73. package/src/lineage/path-store.js +191 -17
  74. package/src/lineage/scc-solver.js +186 -0
  75. package/src/lineage/smt-orchestrator.js +138 -0
  76. package/src/lineage/source-seeding.js +36 -2
  77. package/src/lineage/summaries.js +66 -4
  78. package/src/lineage/value-versions.js +130 -0
  79. package/src/lineage/witness-selection.js +95 -0
  80. package/src/pipeline/scan-health.js +19 -0
  81. package/src/posture/CLAUDE.md +13 -0
  82. package/src/posture/evidence-bundle.js +1 -1
  83. package/src/posture/witness-bundle.js +573 -0
  84. package/src/posture/witness-runtime.js +276 -0
  85. package/src/report/index.js +2 -0
@@ -1,4 +1,8 @@
1
1
  import { accessPathOf, pathIsCoveredByPrefix } from '../dataflow/access-paths.js';
2
+ import {
3
+ CORRELATION_VERSION_2, deriveChild, relPathOf, invocationIdOf, scopeForSubPath,
4
+ } from './correlation.js';
5
+ import { computeReachingDefinitions, ENTRY_VERSION } from './value-versions.js';
2
6
  import { identitiesAt, emptyState, removeIdentitiesAt, addIdentity, joinStates, statesEqual, hashState } from './field-identity.js';
3
7
 
4
8
  function noIdentity() {
@@ -57,6 +61,68 @@ function pathHasWildcard(path) {
57
61
  return path.split('.').includes('*');
58
62
  }
59
63
 
64
+ // --- Correlation version 2 (DAG-03) -------------------------------------
65
+ // Everything below is inert unless the caller set `ctx.correlationVersion`
66
+ // to 2 AND attached a recorder. With either missing, `sub` returns the ctx it
67
+ // was given (the same reference), no field is added to any hop, and the
68
+ // analysis result and the hop stream are byte-identical to version 1.
69
+ function isV2(ctx) {
70
+ return ctx?.correlationVersion === CORRELATION_VERSION_2 && typeof ctx.recordHop === 'function';
71
+ }
72
+
73
+ // Enters one sub-expression: every hop emitted at or below it is stamped with
74
+ // the derivation path of the level that emitted it (the innermost stamp
75
+ // wins, because an inner wrapper's fields are already on the hop when the
76
+ // outer wrapper sees it).
77
+ function sub(ctx, seg, stamp) {
78
+ if (!isV2(ctx)) return ctx;
79
+ const derivation = deriveChild(ctx.derivation, seg);
80
+ const base = ctx.recordHop;
81
+ return { ...ctx, derivation, recordHop: (h) => base({ derivationId: derivation, ...stamp, ...h }) };
82
+ }
83
+
84
+ // The invocation id of the call expression currently being resolved.
85
+ function currentInvocation(ctx) {
86
+ const site = ctx?.siteInfo;
87
+ return invocationIdOf({ scope: site?.scope, nodeId: site?.nodeId, context: site?.context, derivation: ctx?.derivation });
88
+ }
89
+
90
+ // Recorder-only context for resolving the `position`-th ACTUAL argument of a
91
+ // call during parameter binding (summaries.js). Version 1: exactly the
92
+ // recorder-only derivation it always was. Version 2: the argument subtree is
93
+ // stamped with the invocation, its actual position and its derivation, so an
94
+ // argument read can pair only with the binding of the same invocation and
95
+ // formal position.
96
+ export function argumentContext(ctx, position) {
97
+ if (!ctx?.recordHop) return undefined;
98
+ if (!isV2(ctx)) return { recordHop: ctx.recordHop };
99
+ const base = {
100
+ recordHop: ctx.recordHop, correlationVersion: ctx.correlationVersion,
101
+ derivation: ctx.derivation, siteInfo: ctx.siteInfo,
102
+ };
103
+ return sub(base, `arg${position}`, { callRole: 'arg', invocationId: currentInvocation(ctx), argPosition: position });
104
+ }
105
+
106
+ /** The invocation id of the call expression a call-level ctx is resolving (version 2). */
107
+ export function callInvocationId(ctx) {
108
+ return isV2(ctx) ? currentInvocation(ctx) : null;
109
+ }
110
+
111
+ /** The recorder a callee's own analysis must be handed (see `rawRecordHop`). */
112
+ export function calleeRecorder(ctx) {
113
+ return ctx?.rawRecordHop ?? ctx?.recordHop;
114
+ }
115
+
116
+ // The out-half correlation stamp for a write of byPath entry `subPath` of the
117
+ // value built by `source` (rooted at derivation `root`).
118
+ function outStamp(ctx, source, subPath, root) {
119
+ if (!isV2(ctx)) return null;
120
+ const { scope, rest } = scopeForSubPath(source, subPath, root);
121
+ const allocKind = source?.kind === 'object' ? 'object'
122
+ : (source?.kind === 'ident' || source?.kind === 'member') ? 'alias' : null;
123
+ return { derivationScope: scope, relPath: rest, allocKind };
124
+ }
125
+
60
126
  // --- Path provenance hop recording (Sub-project C, increment 1) ---------
61
127
  // See DESIGN_PATH_PROVENANCE.md for the full design. Only two helpers below
62
128
  // are new; everything else in this file is unchanged in shape, and the
@@ -132,6 +198,7 @@ export function resolveExprIdentities(state, expr, ctx) {
132
198
  fromPath: key, toPath: null, dataElementId: id,
133
199
  syntacticPath: key === path ? null : path,
134
200
  widenReason: null, lossReason: null,
201
+ ...(isV2(ctx) ? { relPath: relPathOf(key, path) } : null),
135
202
  });
136
203
  }
137
204
  }
@@ -199,6 +266,7 @@ export function resolveExprIdentities(state, expr, ctx) {
199
266
  fromPath: key, toPath: null, dataElementId: id,
200
267
  syntacticPath: key === path ? null : path,
201
268
  widenReason: null, lossReason: null,
269
+ ...(isV2(ctx) ? { relPath: relPathOf(key, path) } : null),
202
270
  });
203
271
  }
204
272
  }
@@ -218,7 +286,7 @@ export function resolveExprIdentities(state, expr, ctx) {
218
286
  // `assign`/`object` already use) — a coarse/ancestor-level fact on the
219
287
  // base conservatively applies to every field read off it, same
220
288
  // reasoning as `identitiesAt`'s ancestor coverage for state-backed reads.
221
- const base = resolveExprIdentities(state, expr.object, ctx);
289
+ const base = resolveExprIdentities(state, expr.object, sub(ctx, 'base'));
222
290
  if (expr.prop === '*') {
223
291
  // Same reasoning as the path-succeeds branch above: unknown key on a
224
292
  // non-path base — conservatively use everything the base carries
@@ -282,7 +350,7 @@ export function resolveExprIdentities(state, expr, ctx) {
282
350
  const flat = new Set();
283
351
  const byPath = new Map();
284
352
  for (const prop of expr.props) {
285
- const r = resolveExprIdentities(state, prop.value, ctx);
353
+ const r = resolveExprIdentities(state, prop.value, sub(ctx, prop.spread ? 'sp' : `p:${prop.key}`));
286
354
  for (const id of r.flat) flat.add(id);
287
355
  // Path provenance (Sub-project C, increment 1, Task 2 instrumented
288
356
  // site 2 of 4): one production/object in-half per id this property
@@ -358,8 +426,10 @@ export function resolveExprIdentities(state, expr, ctx) {
358
426
 
359
427
  case 'array': {
360
428
  const flat = new Set();
429
+ let ei = 0;
361
430
  for (const el of expr.elements) {
362
- const r = resolveExprIdentities(state, el, ctx);
431
+ ei += 1;
432
+ const r = resolveExprIdentities(state, el, sub(ctx, `e${ei}`));
363
433
  for (const id of r.flat) flat.add(id);
364
434
  }
365
435
  // Path provenance (§10.1 `array` row): structure-flattening by
@@ -380,8 +450,10 @@ export function resolveExprIdentities(state, expr, ctx) {
380
450
 
381
451
  case 'tpl': {
382
452
  const flat = new Set();
453
+ let pi = 0;
383
454
  for (const part of expr.parts) {
384
- const r = resolveExprIdentities(state, part, ctx);
455
+ pi += 1;
456
+ const r = resolveExprIdentities(state, part, sub(ctx, `t${pi}`));
385
457
  for (const id of r.flat) flat.add(id);
386
458
  }
387
459
  // Path provenance (§10.1 `tpl` row): transformation-bearing — the
@@ -404,8 +476,8 @@ export function resolveExprIdentities(state, expr, ctx) {
404
476
  // structure-flattening by design, not a gap. See
405
477
  // DESIGN_INTRAPROCEDURAL.md §4's "structure-preserving vs.
406
478
  // structure-flattening" invariant.
407
- const left = resolveExprIdentities(state, expr.left, ctx);
408
- const right = resolveExprIdentities(state, expr.right, ctx);
479
+ const left = resolveExprIdentities(state, expr.left, sub(ctx, 'l'));
480
+ const right = resolveExprIdentities(state, expr.right, sub(ctx, 'r'));
409
481
  const flat = new Set([...left.flat, ...right.flat]);
410
482
  // Path provenance (§10.1 `binary` row): same as `tpl` — deliberately
411
483
  // a separate case (not shared) per ADR §4, but the hop shape agrees.
@@ -428,8 +500,8 @@ export function resolveExprIdentities(state, expr, ctx) {
428
500
  // byPath, merged per sub-path across both operands, the same way
429
501
  // `union` merges per sub-path across branches. See
430
502
  // DESIGN_INTRAPROCEDURAL.md §4.
431
- const left = resolveExprIdentities(state, expr.left, ctx);
432
- const right = resolveExprIdentities(state, expr.right, ctx);
503
+ const left = resolveExprIdentities(state, expr.left, sub(ctx, 'l'));
504
+ const right = resolveExprIdentities(state, expr.right, sub(ctx, 'r'));
433
505
  const flat = new Set([...left.flat, ...right.flat]);
434
506
  const byPath = new Map();
435
507
  for (const r of [left, right]) {
@@ -461,8 +533,10 @@ export function resolveExprIdentities(state, expr, ctx) {
461
533
  // collapse into one coarse flat blob. See DESIGN_INTRAPROCEDURAL.md §4.
462
534
  const flat = new Set();
463
535
  const byPath = new Map();
536
+ let bi = 0;
464
537
  for (const branch of expr.branches) {
465
- const r = resolveExprIdentities(state, branch, ctx);
538
+ bi += 1;
539
+ const r = resolveExprIdentities(state, branch, sub(ctx, `b${bi}`));
466
540
  for (const id of r.flat) flat.add(id);
467
541
  for (const [subPath, ids] of r.byPath) {
468
542
  const existing = byPath.get(subPath) ?? new Set();
@@ -529,6 +603,7 @@ export function resolveExprIdentities(state, expr, ctx) {
529
603
  fromPath: null, toPath: null, dataElementId: id,
530
604
  syntacticPath: null, widenReason: null, lossReason: null,
531
605
  peerScope: summary.resolvedQid ?? null, peerContext: summary.resolvedContext ?? null,
606
+ ...(isV2(ctx) ? { callRole: 'return', invocationId: currentInvocation(ctx), relPath: '' } : null),
532
607
  });
533
608
  }
534
609
  }
@@ -577,11 +652,13 @@ export function resolveExprIdentities(state, expr, ctx) {
577
652
  for (const id of identitiesAt(state, receiverPath)) flat.add(id);
578
653
  }
579
654
  } else if (expr.callee?.kind === 'member' && expr.callee.object) {
580
- const receiver = resolveExprIdentities(state, expr.callee.object, ctx);
655
+ const receiver = resolveExprIdentities(state, expr.callee.object, sub(ctx, 'recv', { receiver: true }));
581
656
  for (const id of receiver.flat) flat.add(id);
582
657
  }
658
+ let ai = 0;
583
659
  for (const arg of expr.args ?? []) {
584
- const r = resolveExprIdentities(state, arg, ctx);
660
+ const r = resolveExprIdentities(state, arg, sub(ctx, `a${ai}`));
661
+ ai += 1;
585
662
  for (const id of r.flat) flat.add(id);
586
663
  }
587
664
  // Path provenance (§10.1 `call` unresolved row): an unresolved call's
@@ -608,7 +685,7 @@ export function resolveExprIdentities(state, expr, ctx) {
608
685
  // A simple pass-through of whatever the assignment's source resolves
609
686
  // to (structure-preserving), so its byPath is forwarded directly, not
610
687
  // dropped. See DESIGN_INTRAPROCEDURAL.md §4.
611
- const r = resolveExprIdentities(state, expr.source, ctx);
688
+ const r = resolveExprIdentities(state, expr.source, sub(ctx, 'v'));
612
689
  // Path provenance (§10.1 `assign-expr` row): pure pass-through — the
613
690
  // source's own recursion already emitted its own in-halves; this hop
614
691
  // forwards the source's `widened` flag, so it forwards an approximate
@@ -694,7 +771,7 @@ function step(node, stateIn, widenings, ctx) {
694
771
  // `ctx?.recordHop` — mirroring Decision 1's own "extra computation
695
772
  // is fine to skip, but only when it is genuinely side-effect-free"
696
773
  // intent, which this call never actually satisfied.
697
- const resolved = resolveExprIdentities(stateIn, node.source, ctx);
774
+ const resolved = resolveExprIdentities(stateIn, node.source, sub(ctx, 'src'));
698
775
  if (ctx?.recordHop) {
699
776
  const allIds = new Set([...residualFlat(resolved.flat, resolved.byPath), ...[...resolved.byPath.values()].flatMap((s) => [...s])]);
700
777
  for (const id of allIds) {
@@ -707,7 +784,7 @@ function step(node, stateIn, widenings, ctx) {
707
784
  }
708
785
  return { state: stateIn, returnFact: null };
709
786
  }
710
- const resolved = resolveExprIdentities(stateIn, node.source, ctx);
787
+ const resolved = resolveExprIdentities(stateIn, node.source, sub(ctx, 'src'));
711
788
  if (pathHasWildcard(node.target)) {
712
789
  // A computed-key write (`obj[k] = ...`) must be a WEAK update (add to
713
790
  // whatever the container already carries, never clear it first) — a
@@ -739,6 +816,7 @@ function step(node, stateIn, widenings, ctx) {
739
816
  kind: 'write-out', subKind: 'assign-weak',
740
817
  fromPath: null, toPath: containerPath, dataElementId: id,
741
818
  syntacticPath: node.target, widenReason: 'dynamic-property-key', lossReason: null,
819
+ ...outStamp(ctx, node.source, '', '/src'),
742
820
  });
743
821
  }
744
822
  }
@@ -783,6 +861,7 @@ function step(node, stateIn, widenings, ctx) {
783
861
  kind: 'write-out', subKind: 'assign',
784
862
  fromPath: null, toPath: node.target, dataElementId: id,
785
863
  syntacticPath: null, widenReason: assignWidenReason, lossReason: null,
864
+ ...outStamp(ctx, node.source, '', '/src'),
786
865
  });
787
866
  }
788
867
  }
@@ -795,6 +874,7 @@ function step(node, stateIn, widenings, ctx) {
795
874
  kind: 'write-out', subKind: 'assign',
796
875
  fromPath: null, toPath, dataElementId: id,
797
876
  syntacticPath: null, widenReason: assignWidenReason, lossReason: null,
877
+ ...outStamp(ctx, node.source, subPath, '/src'),
798
878
  });
799
879
  }
800
880
  }
@@ -819,8 +899,10 @@ function step(node, stateIn, widenings, ctx) {
819
899
  // itself does with the result (still only checks `r.flat` for a
820
900
  // widening event) — it only lets a nested `call` sub-expression
821
901
  // resolve precisely when `ctx` makes that possible.
902
+ let argIdx = -1;
822
903
  for (const arg of node.args ?? []) {
823
- const r = resolveExprIdentities(stateIn, arg, ctx);
904
+ argIdx += 1;
905
+ const r = resolveExprIdentities(stateIn, arg, sub(ctx, `a${argIdx}`));
824
906
  if (r.flat.size > 0) {
825
907
  widenings.push({ atPath: null, dataElementIds: [...r.flat], reason: 'unresolved-call-arg', line: node.line });
826
908
  }
@@ -836,6 +918,7 @@ function step(node, stateIn, widenings, ctx) {
836
918
  kind: 'write-out', subKind: 'call-arg',
837
919
  fromPath: null, toPath: null, dataElementId: id,
838
920
  syntacticPath: null, widenReason: null, lossReason: null,
921
+ ...(isV2(ctx) ? { derivationScope: `/a${argIdx}`, relPath: '' } : null),
839
922
  });
840
923
  }
841
924
  }
@@ -844,7 +927,7 @@ function step(node, stateIn, widenings, ctx) {
844
927
  }
845
928
 
846
929
  case 'return': {
847
- const resolved = node.value ? resolveExprIdentities(stateIn, node.value, ctx) : { flat: new Set(), widened: false };
930
+ const resolved = node.value ? resolveExprIdentities(stateIn, node.value, sub(ctx, 'val')) : { flat: new Set(), widened: false };
848
931
  if (resolved.widened && resolved.flat.size > 0) {
849
932
  widenings.push({ atPath: null, dataElementIds: [...resolved.flat], reason: 'unresolved-call', line: node.line });
850
933
  }
@@ -863,6 +946,7 @@ function step(node, stateIn, widenings, ctx) {
863
946
  kind: 'write-out', subKind: 'return',
864
947
  fromPath: null, toPath: null, dataElementId: id,
865
948
  syntacticPath: null, widenReason: returnWidenReason, lossReason: null,
949
+ ...(isV2(ctx) ? { derivationScope: '/val', relPath: '' } : null),
866
950
  });
867
951
  }
868
952
  }
@@ -892,6 +976,26 @@ function step(node, stateIn, widenings, ctx) {
892
976
  // not a load-bearing part of the termination proof.
893
977
  const ITER_BUDGET = 5000;
894
978
 
979
+ // Version 2 labels for one hop: the definitions reaching a read, and the
980
+ // definition a local write creates. A write into another function's
981
+ // namespace (a call-arg-bind) is labelled by the resolver instead.
982
+ function versionStamp(reaching, nid, h, entryState) {
983
+ const out = {};
984
+ if (typeof h.fromPath === 'string') {
985
+ let vs = reaching.versionsAt(nid, h.fromPath);
986
+ // The entry value is an alternative only if the entry state really
987
+ // carried this id at this path; a merge with an unwritten branch must
988
+ // not invent an origin for it.
989
+ if (vs.includes(ENTRY_VERSION) && !entryState.get(h.fromPath)?.has(h.dataElementId)) {
990
+ const rest = vs.filter((v) => v !== ENTRY_VERSION);
991
+ if (rest.length > 0) vs = rest;
992
+ }
993
+ out.fromVersions = vs;
994
+ }
995
+ if (h.kind === 'write-out' && typeof h.toPath === 'string' && h.peerScope == null) out.toVersion = nid;
996
+ return out;
997
+ }
998
+
895
999
  export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
896
1000
  const nodes = fn.cfg.nodes;
897
1001
  const work = [fn.cfg.entry];
@@ -900,6 +1004,16 @@ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
900
1004
  const widenings = [];
901
1005
  const returnFactsByNode = new Map();
902
1006
  let iterations = 0;
1007
+ // Completion record (DAG-07 R07.1): a budget-exhausted run used to fall
1008
+ // out of the loop with a bare `break` and return an empty exit state that
1009
+ // was indistinguishable from "analyzed, nothing flows". `maxIterations`
1010
+ // is overridable through ctx so the limit is testable without building a
1011
+ // 5,000-node CFG; a non-integer or non-positive override is ignored.
1012
+ const maxIterations = Number.isInteger(ctx?.maxIterations) && ctx.maxIterations > 0
1013
+ ? ctx.maxIterations : ITER_BUDGET;
1014
+ let budgetExhausted = false;
1015
+ let cancelled = false;
1016
+ const shouldStop = typeof ctx?.shouldStop === 'function' ? ctx.shouldStop : null;
903
1017
  // Path provenance (Sub-project C, increment 1, Decision 7.2): stamped
904
1018
  // once for the whole analysis, since resolveExprIdentities never sees the
905
1019
  // enclosing function.
@@ -912,9 +1026,17 @@ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
912
1026
  // two hops share a `context` iff the cache would consider them the same
913
1027
  // context.
914
1028
  const context = ctx?.recordHop ? hashState(entryState) : null;
1029
+ // Correlation version 2 (DAG-03): one reaching-definitions pass per
1030
+ // analysis run; hops are labelled with the definitions their read paths
1031
+ // see and the definition their write creates.
1032
+ const v2 = ctx?.correlationVersion === CORRELATION_VERSION_2 && typeof ctx.recordHop === 'function';
1033
+ const reaching = v2 ? computeReachingDefinitions(fn) : null;
915
1034
 
916
1035
  while (work.length) {
917
- if (++iterations > ITER_BUDGET) break;
1036
+ if (++iterations > maxIterations) { budgetExhausted = true; break; }
1037
+ // R07.7: cooperative cancellation, polled every 64 iterations so a
1038
+ // deadline costs next to nothing on the common path.
1039
+ if (shouldStop && (iterations & 63) === 1 && shouldStop()) { cancelled = true; break; }
918
1040
  const nid = work.shift();
919
1041
  const node = nodes[nid];
920
1042
  if (!node) continue;
@@ -927,12 +1049,24 @@ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
927
1049
  // both). With no recorder present, `stepCtx` is the SAME `ctx` reference
928
1050
  // — no allocation, nothing for a backward-compatibility test to catch
929
1051
  // (Decision 7.2's "true by construction" point).
930
- const stepCtx = ctx?.recordHop
931
- ? { ...ctx, recordHop: (h) => ctx.recordHop({
1052
+ const stepRecord = ctx?.recordHop
1053
+ ? (h) => ctx.recordHop({
932
1054
  scope, nodeId: nid, line: node.line ?? null,
933
1055
  context, peerScope: null, peerContext: null,
1056
+ ...(v2 ? versionStamp(reaching, nid, h, entryState) : null),
934
1057
  ...h,
935
- }) }
1058
+ })
1059
+ : null;
1060
+ // `rawRecordHop` (version 2 only) is this level's recorder BELOW the
1061
+ // per-expression derivation wrappers, which is what a callee's analysis
1062
+ // must be handed: the callee's hops carry their own derivation ids and
1063
+ // must not inherit the caller's expression-tree stamps.
1064
+ const stepCtx = stepRecord
1065
+ ? {
1066
+ ...ctx,
1067
+ ...(v2 ? { siteInfo: { scope, nodeId: nid, context }, rawRecordHop: stepRecord } : null),
1068
+ recordHop: stepRecord,
1069
+ }
936
1070
  : ctx;
937
1071
  const { state: out, returnFact } = step(node, incoming, widenings, stepCtx);
938
1072
  if (returnFact && returnFact.size > 0) {
@@ -988,5 +1122,16 @@ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
988
1122
  if (ids.size > 0) mutatedParams.set(param, ids);
989
1123
  }
990
1124
 
991
- return { exitState, returnFacts, mutatedParams, widenings };
1125
+ const completion = (budgetExhausted || cancelled)
1126
+ ? {
1127
+ status: cancelled ? 'cancelled' : 'partial',
1128
+ reasons: [cancelled ? 'cancelled' : 'analysis-iteration-budget'],
1129
+ iterations: maxIterations,
1130
+ maxIterations,
1131
+ unfinishedWork: work.length,
1132
+ exitReached: outStates.has(fn.cfg.exit),
1133
+ }
1134
+ : { status: 'complete', reasons: [], iterations, maxIterations, unfinishedWork: 0, exitReached: outStates.has(fn.cfg.exit) };
1135
+
1136
+ return { exitState, returnFacts, mutatedParams, widenings, completion };
992
1137
  }
@@ -14,6 +14,7 @@
14
14
  // (the `agentic-security scan` and `agentic-security ci` commands). This
15
15
  // module computes its own content digest instead of reusing either.
16
16
 
17
+ import { completionOfGraph } from './completion.js';
17
18
  import * as crypto from 'node:crypto';
18
19
  import { _redactGraph } from './redact-graph.js';
19
20
 
@@ -232,6 +233,10 @@ export function exportGraphJSON(graph, opts = {}) {
232
233
  scope: graph?.scope ?? null,
233
234
  coverage: graph?.coverage ?? null,
234
235
  limitations: graph?.limitations ?? [],
236
+ // DAG-07 R07.2: the completion envelope, derived from the source graph
237
+ // so it is the same for any filter/redaction of one scan. Top-level and
238
+ // additive: the digests above cover `graph`, which is unchanged.
239
+ completion: completionOfGraph(graph),
235
240
  confidential: true,
236
241
  graph: body,
237
242
  };
@@ -133,6 +133,8 @@ import { emptyProtection, aggregateVerdicts } from './protection.js';
133
133
  import * as ids from './ids.js';
134
134
  import { planSeeds, seedEntryStateFactory, exprRoots, walkExpr } from './source-seeding.js';
135
135
  import { RECIPIENT_FACT_FIELDS } from './recipient-profile.js';
136
+ import { pathEvidenceEnabled, evaluatePathEvidence, summarizeFlowEvidence } from './path-evidence.js';
137
+ import { sinkFamilyOf } from './control-credit.js';
136
138
 
137
139
  // =========================================================================
138
140
  // §4.2 / §4.3 — registry-backed sink enumeration, multi-candidate resolution
@@ -438,16 +440,46 @@ const nodeLabel = (kind, category, reason) => (category
438
440
  */
439
441
  export function buildDataFlowGraph(callGraph, opts = {}) {
440
442
  const repository = opts.repository ?? 'repo';
441
- const { seeds, unseedable } = planSeeds(callGraph, { repository });
443
+ const { seeds, unseedable, completion: seedingCompletion } = planSeeds(callGraph, {
444
+ repository,
445
+ ...(opts.seeding ?? {}),
446
+ ...(opts.signal ? { signal: opts.signal } : {}),
447
+ ...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
448
+ });
442
449
  const hops = [];
443
- runFieldIdentityAnalysis(callGraph, {
450
+ const analysis = runFieldIdentityAnalysis(callGraph, {
444
451
  recordHop: (h) => hops.push(h),
445
452
  seedEntryState: seedEntryStateFactory(seeds),
446
453
  ...(opts.maxContextsPerFn === undefined ? {} : { maxContextsPerFn: opts.maxContextsPerFn }),
454
+ ...(opts.maxIterations === undefined ? {} : { maxIterations: opts.maxIterations }),
455
+ // DAG-03: provenance correlation version 2 (off unless requested).
456
+ ...(opts.correlationVersion === undefined ? {} : { correlationVersion: opts.correlationVersion }),
457
+ ...(opts.signal ? { signal: opts.signal } : {}),
458
+ ...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
447
459
  ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
448
460
  });
449
- const store = new PathStore();
461
+ const store = new PathStore({}, { correlationVersion: opts.correlationVersion });
450
462
  store.addHops(hops);
463
+ // DAG-07 R07.2: an analysis unit that hit its budget has no hop
464
+ // representation (DESIGN_PATH_PROVENANCE.md section 14.8), so it reaches
465
+ // the store through the out-of-band channel built for exactly this. Units
466
+ // that emitted nothing stay visible through `analysisCompletion` below.
467
+ const analysisCompletion = { ...analysis.completion };
468
+ const _storeLimits = store.limitStatus();
469
+ if (_storeLimits.hit) {
470
+ analysisCompletion.storeLimits = _storeLimits;
471
+ if (analysisCompletion.status === 'complete') analysisCompletion.status = 'partial';
472
+ }
473
+ // R07.1: the seeding step's own record. Seeds the planner never reached
474
+ // mean functions whose sources were never looked for.
475
+ if (seedingCompletion.status !== 'complete') {
476
+ analysisCompletion.seeding = seedingCompletion;
477
+ if (seedingCompletion.status === 'cancelled') analysisCompletion.status = 'cancelled';
478
+ else if (analysisCompletion.status === 'complete') analysisCompletion.status = 'partial';
479
+ }
480
+ for (const u of analysisCompletion.incomplete) {
481
+ store.markTruncated(u.qid, u.context, u.reasons[0] ?? 'analysis-incomplete');
482
+ }
451
483
 
452
484
  const cfgByQid = new Map();
453
485
  for (const fn of callGraph.functions.values()) cfgByQid.set(fn.qid, { fn, nodes: fn.cfg?.nodes ?? {} });
@@ -740,7 +772,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
740
772
  // key by design and therefore never splits a flow.
741
773
  const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
742
774
  if (!groupsByFlowKey.has(flowKey)) groupsByFlowKey.set(flowKey, []);
743
- groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons });
775
+ groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons, result: r });
744
776
  }
745
777
  }
746
778
  site.connected = connected;
@@ -750,6 +782,22 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
750
782
  sinkNodeFor(site);
751
783
  }
752
784
 
785
+ // DAG-04: opt-in path predicates and control credit (see path-evidence.js).
786
+ const pathEvidenceOn = pathEvidenceEnabled(opts);
787
+ const _fnOf = (q) => cfgByQid.get(q)?.fn ?? null;
788
+ const pathEvidenceFor = (group) => {
789
+ const paths = group.map((x) => x.p);
790
+ const family = sinkFamilyOf(group[0].site.entry);
791
+ const evidences = paths.map((p) => evaluatePathEvidence({ path: p, nodeOf: (id) => store.getNode(id), fnOf: _fnOf, sinkFamily: family }));
792
+ const results = [...new Set(group.map((x) => x.result))];
793
+ const merged = {
794
+ status: results.every((x) => x.status === 'complete') ? 'complete' : 'partial',
795
+ truncated: results.some((x) => x.truncated), droppedPathCount: results.reduce((n, x) => n + (x.droppedPathCount ?? 0), 0),
796
+ analysisTruncated: results.some((x) => x.analysisTruncated), resumed: results.some((x) => x.resumed),
797
+ };
798
+ return summarizeFlowEvidence({ result: merged, paths, evidences });
799
+ };
800
+
753
801
  for (const [, group] of [...groupsByFlowKey.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
754
802
  const { p, g, src, snk, de, edgeIdStr, sortedT, site } = group[0];
755
803
  const fId = ids.flowId(src.id, snk.id, [de.id], [p.shape, g.grade, sortedT.join(',')]);
@@ -927,7 +975,16 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
927
975
  limitations,
928
976
  evidenceGrade: g.grade,
929
977
  handling: handlingResult,
978
+ // DAG-04 (default OFF; fields exist only when enabled, so default graphs and digests do not move):
979
+ // per-path branch conditions, feasibility under the bounded model, and control credit for the sink
980
+ // family. Alternatives stay distinct; infeasible-under-model paths are listed, not dropped.
981
+ ...(pathEvidenceOn ? { pathEvidence: pathEvidenceFor(group) } : {}),
930
982
  });
983
+ if (pathEvidenceOn) {
984
+ const fe = edgesById.get(edgeIdStr);
985
+ const refs = [...new Set(flowsById.get(fId).pathEvidence.alternatives.flatMap((a) => a.predicateRefs.map((x) => x.ref)))].sort();
986
+ if (fe && refs.length) fe.predicateRefs = [...new Set([...(fe.predicateRefs ?? []), ...refs])].sort();
987
+ }
931
988
  }
932
989
 
933
990
  // Milestone 2, Sub-project D, increment 2 (FR-307): appliesToAllPaths.
@@ -1105,15 +1162,35 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1105
1162
  },
1106
1163
  degradedTerminals: degraded.length,
1107
1164
  paths: { enumerated: stats.pathsEnumerated, projected: stats.pathsProjected, truncatedQueries: stats.truncatedQueries },
1108
- provenance: { hops: hops.length, pnodes: store.stats().nodes, pedges: store.stats().edges },
1165
+ provenance: {
1166
+ hops: hops.length, pnodes: store.stats().nodes, pedges: store.stats().edges,
1167
+ ...(store.correlationVersion() === 2 ? { correlationVersion: 2 } : null),
1168
+ },
1169
+ ...(analysisCompletion.status === 'complete' ? {} : { analysisCompletion }),
1109
1170
  };
1110
1171
  graph.limitations = [
1111
1172
  'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
1112
1173
  'Policy verdicts are not evaluated in Milestone 1.',
1113
1174
  'External destinations are not resolved (FR-202 is Milestone 2); protocol.destinationResolution is "unknown" on every edge.',
1114
1175
  ];
1115
- graph.scanHealth = { status: 'complete', reason: 'lineage-graph-builder' };
1176
+ graph.scanHealth = analysisCompletion.status === 'complete'
1177
+ ? { status: 'complete', reason: 'lineage-graph-builder', analysisCompletion }
1178
+ : {
1179
+ status: analysisCompletion.status === 'cancelled' ? 'cancelled' : 'partial',
1180
+ reason: analysisCompletion.status === 'cancelled' ? 'analysis-cancelled' : 'analysis-incomplete',
1181
+ analysisCompletion,
1182
+ };
1183
+ if (analysisCompletion.status !== 'complete') {
1184
+ const parts = [];
1185
+ if (analysisCompletion.incomplete.length) parts.push(`field analysis stopped early in ${analysisCompletion.incomplete.length} of ${analysisCompletion.functionsAnalyzed} function(s) (iteration budget)`);
1186
+ if (analysisCompletion.status === 'cancelled') parts.push(`analysis was cancelled; ${analysisCompletion.functionsNotAnalyzed} function(s) were never analyzed`);
1187
+ if (analysisCompletion.storeLimits) parts.push('provenance construction limits were reached, so some flows were not materialized');
1188
+ if (analysisCompletion.seeding) parts.push(`source seeding stopped early (${analysisCompletion.seeding.reasons.join(', ')}); ${analysisCompletion.seeding.functionsNotScanned} function(s) were not searched for sources`);
1189
+ if (analysisCompletion.incremental) parts.push('a cached summary stood in for a tainted entry context');
1190
+ if (analysisCompletion.degraded.length) parts.push(`${analysisCompletion.degraded.length} call context(s) fell back to a coarser summary (context cap or unconverged recursion)`);
1191
+ graph.limitations.push(`${parts.join('; ')}; absence of a flow from those functions is not evidence that none exists.`);
1192
+ }
1116
1193
  graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
1117
1194
 
1118
- return { graph, store, hops, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
1195
+ return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
1119
1196
  }
@@ -317,3 +317,62 @@ export function adaptCallGraphForLineage(callGraph) {
317
317
  const resolveKnownCallee = callGraph.resolveKnownCallee ? (...a) => swap(callGraph.resolveKnownCallee(...a)) : undefined;
318
318
  return { ...callGraph, functions, ...(resolve ? { resolve } : {}), ...(resolveKnownCallee ? { resolveKnownCallee } : {}), lineageView: { haskell: true, functions: clones.size } };
319
319
  }
320
+
321
+ // ── DAG-08: boundary view (record and monadic bindings, unresolved higher-order constructs) ──────────────────────────────
322
+ // A boundary contract that names a Haskell consumer (a function and one of its parameters) is bound by
323
+ // lineage/boundary-contract.js against the same parser-resolved functions this view clones. These helpers add what a consumer
324
+ // binding needs from this layer and must not hide:
325
+ // * the ORIGINAL source location of the bound function next to the lineage-view clone's location (both kept, neither invented);
326
+ // * every higher-order construct (`mapM_ f xs`, `forM_ xs k`, `traverse_`, `>>=` with a function value) whose function
327
+ // argument the Haskell resolver could not resolve to a project function. Taint through such a construct is not followed, so it
328
+ // is listed instead of being treated as resolved.
329
+ const HIGHER_ORDER_VIA = /^(?:mapM_?|forM_?|traverse_?|for_|foldM_?|foldr|foldl'?|fmap|<\$>|>>=|=<<|bracket_?|handle|catch|forever|when|unless|zipWithM_?)$/;
330
+
331
+ /**
332
+ * @param {object} callGraph the Haskell call graph (original or lineage-adapted)
333
+ * @returns {{functions: object[], unresolvedHigherOrder: object[], notInlined: object[]}}
334
+ */
335
+ export function describeHaskellBoundaries(callGraph) {
336
+ const out = { functions: [], unresolvedHigherOrder: [], notInlined: [] };
337
+ if (!callGraph || typeof callGraph.functions?.values !== 'function') return out;
338
+ for (const fn of callGraph.functions.values()) {
339
+ if (!isHsFile(fn.file) || !fn.cfg) continue;
340
+ out.functions.push({
341
+ qid: fn.qid, name: fn.name, file: fn.file, line: fn.line,
342
+ view: fn.hs && fn.hs.lineageView ? { adapted: true, inlinedCalls: fn.hs.lineageInlinedCalls || 0 } : { adapted: false },
343
+ recordParams: ((fn.hs && fn.hs.recordParams) || []).map((r) => ({ name: r.name, type: r.type, fields: [...(r.fields || [])] })),
344
+ });
345
+ for (const s of (fn.hs && fn.hs.lineageNotInlined) || []) out.notInlined.push({ function: fn.qid, file: fn.file, line: fn.line, callee: s.callee, reason: s.reason });
346
+ for (const n of Object.values(fn.cfg.nodes)) {
347
+ const call = n.kind === 'call' ? n : (n.kind === 'assign' && n.source && n.source.kind === 'call' ? n.source : null);
348
+ if (!call || !call.hs) continue;
349
+ const via = call.hs.via;
350
+ if (!via || !HIGHER_ORDER_VIA.test(via)) continue;
351
+ if (call.hs.status === 'resolved' && call.hs.target) continue;
352
+ out.unresolvedHigherOrder.push({
353
+ function: fn.qid, file: fn.file, line: n.line ?? call.line ?? fn.line, via, status: call.hs.status || 'unknown',
354
+ callee: typeof call.callee === 'string' ? call.callee : null,
355
+ reason: call.hs.status === 'param' ? 'the function argument is a parameter of the enclosing function' : 'the function argument was not resolved to a project function',
356
+ });
357
+ }
358
+ }
359
+ const key = (a) => `${a.file}:${String(a.line).padStart(8, '0')}:${a.via}`;
360
+ out.unresolvedHigherOrder.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
361
+ return out;
362
+ }
363
+
364
+ /**
365
+ * The original and lineage-view locations of a bound Haskell consumer. `adapted` is the result of adaptCallGraphForLineage.
366
+ * Both refer to the same source file and line: the view clones the function but never moves or renames it.
367
+ */
368
+ export function haskellConsumerLocations(adapted, binding) {
369
+ if (!binding || !adapted || typeof adapted.functions?.get !== 'function') return null;
370
+ const clone = adapted.functions.get(binding.qid);
371
+ if (!clone) return null;
372
+ const orig = binding.originalLocation || { file: binding.file, line: binding.line };
373
+ return {
374
+ original: { file: orig.file, line: orig.line },
375
+ view: { file: clone.file, line: clone.line, view: 'haskell-lineage', adapted: !!(clone.hs && clone.hs.lineageView), inlinedCalls: (clone.hs && clone.hs.lineageInlinedCalls) || 0 },
376
+ notInlined: (clone.hs && clone.hs.lineageNotInlined) || [],
377
+ };
378
+ }
@@ -277,6 +277,9 @@ export function buildLineageGraph(callGraph, opts = {}) {
277
277
  observationWindowStart: opts.observationWindowStart,
278
278
  observationWindowEnd: opts.observationWindowEnd,
279
279
  onProgress: opts.onProgress,
280
+ // DAG-07 R07.7: cooperative stop, forwarded only when supplied.
281
+ ...(opts.signal ? { signal: opts.signal } : {}),
282
+ ...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
280
283
  });
281
284
  const nixFiles = opts.fileContents && Object.keys(opts.fileContents).some((f) => /\.nix$/i.test(f));
282
285
  if (nixFiles) contributeNixConfigLineage(built.graph, opts.fileContents, { repository: opts.repository });