@clear-capabilities/agentic-security-scanner 0.195.24 → 0.195.25

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.
@@ -111,8 +111,9 @@
111
111
  // entry point), it does not wrap it.
112
112
 
113
113
  import { matchSinkOrSanitizer } from '../dataflow/catalog.js';
114
- import { matchPrivacySink } from '../dataflow/privacy-catalog.js';
114
+ import { matchPrivacySink, PRIVACY_SINK_CATALOG } from '../dataflow/privacy-catalog.js';
115
115
  import { matchOrmWrite } from '../dataflow/orm-write-catalog.js';
116
+ import { collectBoundaryEndpoints, planBoundaryJoins, queueCommandOf, MAX_BOUNDARY_ROUNDS } from './boundary-join.js';
116
117
  // Milestone 2, Sub-project G, increment 1 (FR-408/AC-09): `isSinkPermitted`/
117
118
  // `permittingRules` are pure, vocabulary-agnostic functions (no hardcoded
118
119
  // sink-name check anywhere in that module — confirmed by direct read) that
@@ -195,6 +196,19 @@ function resolveSinkAtCallSite(calleeExpr, file) {
195
196
  };
196
197
  }
197
198
 
199
+ /**
200
+ * GA-03.3 (0.195.25): the registry decision for an AWS SDK v3 send command
201
+ * (`queueCommandOf` in boundary-join.js). Resolves through the privacy
202
+ * catalog's queue entry named by the command table, so the node is the same
203
+ * `queue` decision the v2 `sendMessage`/`publish` shapes get; never through
204
+ * the general catalog, whose bare `send` entry is a different sink.
205
+ */
206
+ function resolveQueueCommandSite(cmd) {
207
+ const entry = PRIVACY_SINK_CATALOG.find((e) => e.id === cmd.entry);
208
+ if (!entry) return null;
209
+ return { entry, decision: reclassifyPrivacySink(entry), ambiguity: null, queueCommand: cmd.command };
210
+ }
211
+
198
212
  /**
199
213
  * Milestone 2, Sub-project E, increment 1 (ORM-write sink recognition,
200
214
  * `docs/superpowers/plans/2026-08-31-data-flow-explorer-m2-subproject-e1-plan.md`).
@@ -345,7 +359,17 @@ export function enumerateSinkSites(callGraph) {
345
359
  for (const fn of callGraph.functions.values()) {
346
360
  for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
347
361
  if (node.kind === 'call' && node.callee) {
348
- const r = resolveSinkAtCallSite(node.callee, fn.file);
362
+ // GA-03.3 (0.195.25): the AWS SDK v3 command shape,
363
+ // `client.send(new SendMessageCommand(input))`. The general catalog's
364
+ // bare `send` entry (a koa-send path sink) used to claim this call as
365
+ // an unresolved file sink; it is a queue publish, resolved through the
366
+ // privacy catalog's own queue entry so the registry decision is the
367
+ // same one `sqs.sendMessage(input)` gets. A RECEIVE command is a read,
368
+ // not a sink: the site is skipped here and becomes a boundary
369
+ // consumer (boundary-join.js) instead.
370
+ const cmd = queueCommandOf(node.callee, node.args ?? []);
371
+ if (cmd && cmd.role === 'receive') continue;
372
+ const r = cmd ? resolveQueueCommandSite(cmd) : resolveSinkAtCallSite(node.callee, fn.file);
349
373
  if (r) {
350
374
  sites.push({ file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null, calleeExpr: node.callee, args: node.args ?? [], ...r });
351
375
  // Milestone 2, Sub-project E, increment 3: queue/topic identity
@@ -356,7 +380,7 @@ export function enumerateSinkSites(callGraph) {
356
380
  // only the queue category needs).
357
381
  if (r.decision.category === 'queue') {
358
382
  const site = sites[sites.length - 1];
359
- site.queueDetail = extractQueueDetail(node.args ?? []);
383
+ site.queueDetail = extractQueueDetail(cmd ? [cmd.input] : (node.args ?? []));
360
384
  }
361
385
  }
362
386
  // Milestone 2, Sub-project E, increment 1: ORM-write recognition,
@@ -414,6 +438,27 @@ function calleeDescriptor(calleeExpr) {
414
438
  }
415
439
  const calleeDisplay = (d) => (!d ? null : d.type === 'member-call' ? `${d.object}.${d.method}` : d.callee);
416
440
 
441
+ /**
442
+ * GA-03.3: the coverage record for handoff joins, the same for the builder's
443
+ * own sketch and coverage.js's finished ledger. Disabled builds say so;
444
+ * nothing is counted that was not attempted.
445
+ */
446
+ export function boundaryCoverage(boundaries) {
447
+ if (!boundaries) return { enabled: false };
448
+ return {
449
+ enabled: true,
450
+ rounds: boundaries.rounds, maxRounds: MAX_BOUNDARY_ROUNDS, truncated: boundaries.truncated,
451
+ producers: boundaries.endpoints.producers, consumers: boundaries.endpoints.consumers,
452
+ joins: boundaries.joins.length,
453
+ joinedFields: boundaries.joins.reduce((n, j) => n + j.fields.length, 0),
454
+ declared: boundaries.joins.filter((j) => j.provenance === 'manual').length,
455
+ discovered: boundaries.joins.filter((j) => j.provenance === 'code').length,
456
+ unjoined: boundaries.unjoined.map((u) => ({ side: u.side, kind: u.kind, file: u.file, line: u.line, reason: u.reason })),
457
+ gaps: boundaries.gaps.length,
458
+ note: 'Every join is modeled from a declared contract or a literal identity both sides name; none is an observed message or row.',
459
+ };
460
+ }
461
+
417
462
  const nodeLabel = (kind, category, reason) => (category
418
463
  ? `${category} (${kind})`
419
464
  : `unsupported ${kind}: ${String(reason).split(';')[0].slice(0, 80)}`);
@@ -446,20 +491,57 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
446
491
  ...(opts.signal ? { signal: opts.signal } : {}),
447
492
  ...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
448
493
  });
449
- const hops = [];
450
- const analysis = runFieldIdentityAnalysis(callGraph, {
451
- recordHop: (h) => hops.push(h),
452
- seedEntryState: seedEntryStateFactory(seeds),
453
- // GA-03.1 (0.195.24): call-shaped sources assigned to a variable.
454
- ...(seedAfterNodeFactory(seeds) ? { seedAfterNode: seedAfterNodeFactory(seeds) } : {}),
455
- ...(opts.maxContextsPerFn === undefined ? {} : { maxContextsPerFn: opts.maxContextsPerFn }),
456
- ...(opts.maxIterations === undefined ? {} : { maxIterations: opts.maxIterations }),
457
- // DAG-03: provenance correlation version 2 (off unless requested).
458
- ...(opts.correlationVersion === undefined ? {} : { correlationVersion: opts.correlationVersion }),
459
- ...(opts.signal ? { signal: opts.signal } : {}),
460
- ...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
461
- ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
462
- });
494
+ const runAnalysis = (seedsNow) => {
495
+ const hopsNow = [];
496
+ const afterNode = seedAfterNodeFactory(seedsNow);
497
+ const analysisNow = runFieldIdentityAnalysis(callGraph, {
498
+ recordHop: (h) => hopsNow.push(h),
499
+ seedEntryState: seedEntryStateFactory(seedsNow),
500
+ // GA-03.1 (0.195.24): call-shaped sources assigned to a variable.
501
+ ...(afterNode ? { seedAfterNode: afterNode } : {}),
502
+ ...(opts.maxContextsPerFn === undefined ? {} : { maxContextsPerFn: opts.maxContextsPerFn }),
503
+ ...(opts.maxIterations === undefined ? {} : { maxIterations: opts.maxIterations }),
504
+ // DAG-03: provenance correlation version 2 (off unless requested).
505
+ ...(opts.correlationVersion === undefined ? {} : { correlationVersion: opts.correlationVersion }),
506
+ ...(opts.signal ? { signal: opts.signal } : {}),
507
+ ...(opts.deadline === undefined ? {} : { deadline: opts.deadline }),
508
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
509
+ });
510
+ return { hops: hopsNow, analysis: analysisNow };
511
+ };
512
+ let { hops, analysis } = runAnalysis(seeds);
513
+ // Sink sites are enumerated once, here, because the boundary rounds below
514
+ // need the producer sites before the projection does. Pure over the call
515
+ // graph, so the order change is not observable.
516
+ const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
517
+ // GA-03.3 (0.195.25): cross-service handoffs (boundary-join.js). Opt-in
518
+ // (`opts.boundaries.enabled`); a build that omits it is byte-identical to
519
+ // the build before this existed. A round plans the consumer seeds the
520
+ // current hop stream supports and, when it minted any, re-runs the field
521
+ // analysis so a chain of handoffs resolves; the round count is bounded and
522
+ // a still-productive last round is reported as truncated, never read as
523
+ // convergence.
524
+ let boundaries = null;
525
+ if (opts.boundaries && opts.boundaries.enabled) {
526
+ const endpoints = collectBoundaryEndpoints(callGraph, sites);
527
+ const contracts = Array.isArray(opts.boundaries.contracts) ? opts.boundaries.contracts : [];
528
+ const minted = new Set();
529
+ const joinsById = new Map();
530
+ boundaries = { enabled: true, rounds: 0, truncated: false, seeds: 0, joins: [], unjoined: endpoints.unjoined, gaps: [], endpoints: { producers: endpoints.producers.length, consumers: endpoints.consumers.length } };
531
+ for (let round = 0; round <= MAX_BOUNDARY_ROUNDS; round++) {
532
+ const r = planBoundaryJoins({ endpoints, hops, contracts, callGraph, minted });
533
+ for (const j of r.joins) if (!joinsById.has(j.joinId) || j.fields.length) joinsById.set(j.joinId, j);
534
+ boundaries.unjoined = r.unjoined;
535
+ boundaries.gaps = r.gaps;
536
+ if (!r.seeds.length) break;
537
+ if (round === MAX_BOUNDARY_ROUNDS) { boundaries.truncated = true; break; }
538
+ seeds.push(...r.seeds);
539
+ boundaries.seeds += r.seeds.length;
540
+ boundaries.rounds += 1;
541
+ ({ hops, analysis } = runAnalysis(seeds));
542
+ }
543
+ boundaries.joins = [...joinsById.values()].sort((a, b) => (a.joinId < b.joinId ? -1 : 1));
544
+ }
463
545
  const store = new PathStore({}, { correlationVersion: opts.correlationVersion });
464
546
  store.addHops(hops);
465
547
  // DAG-07 R07.2: an analysis unit that hit its budget has no hop
@@ -482,6 +564,10 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
482
564
  for (const u of analysisCompletion.incomplete) {
483
565
  store.markTruncated(u.qid, u.context, u.reasons[0] ?? 'analysis-incomplete');
484
566
  }
567
+ if (boundaries && boundaries.truncated) {
568
+ analysisCompletion.boundaries = { rounds: boundaries.rounds, maxRounds: MAX_BOUNDARY_ROUNDS, truncated: true };
569
+ if (analysisCompletion.status === 'complete') analysisCompletion.status = 'partial';
570
+ }
485
571
 
486
572
  const cfgByQid = new Map();
487
573
  for (const fn of callGraph.functions.values()) cfgByQid.set(fn.qid, { fn, nodes: fn.cfg?.nodes ?? {} });
@@ -493,6 +579,20 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
493
579
  if (!seedSitesByDe.has(s.dataElementId)) seedSitesByDe.set(s.dataElementId, []);
494
580
  seedSitesByDe.get(s.dataElementId).push(s);
495
581
  }
582
+ // GA-03.3: one data element can now be seeded in more than one function
583
+ // (its collection point and every consumer a handoff carried it into).
584
+ // The seed a path starts from is the one in the function the path's
585
+ // terminal node lives in; the first seed is the fallback for a path whose
586
+ // terminal is not a seeded location (unchanged behaviour when there is one
587
+ // seed per element, which is every build without boundaries).
588
+ const seedForPath = (p) => {
589
+ const cands = seedSitesByDe.get(p.dataElementId);
590
+ if (!cands || cands.length < 2) return seedByDe.get(p.dataElementId);
591
+ const scope = store.getNode(p.terminal?.nodeId)?.scope ?? store.getNode(p.hops?.[0]?.fromNodeId)?.scope ?? null;
592
+ return cands.find((s) => s.qid === scope) ?? cands[0];
593
+ };
594
+ const siteByKey = new Map();
595
+ for (const site of sites) siteByKey.set(`${site.qid}|${site.nodeId}`, site);
496
596
 
497
597
  const graph = emptyGraphEnvelope({
498
598
  graphId: ids.graphId({ repository, commit: opts.commit ?? 'uncommitted', configHash: opts.configHash ?? 'default' }),
@@ -570,11 +670,22 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
570
670
  decisionsByNodeId.get(id).push({ kind, category, coverageStatus, externality });
571
671
  return n;
572
672
  };
573
- const sourceNodeFor = (s) => mintNode({
574
- kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
575
- coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
576
- lifecycleStages: ['collection'],
577
- });
673
+ // GA-03.3: a seed a handoff minted has no collection point of its own; its
674
+ // source node IS the producer's sink node (the queue or the store), so the
675
+ // graph reads as one chain, HTTP to queue to store to HTTP response, with
676
+ // the handoff visible on the edge. The producer site always exists (a join
677
+ // is planned from a site in `sites`); the fallback below is defensive.
678
+ const sourceNodeFor = (s) => {
679
+ if (s.boundary) {
680
+ const psite = siteByKey.get(`${s.boundary.producer.qid}|${s.boundary.producer.nodeId}`);
681
+ if (psite) return sinkNodeFor(psite);
682
+ }
683
+ return mintNode({
684
+ kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
685
+ coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
686
+ lifecycleStages: ['collection'],
687
+ });
688
+ };
578
689
  const sinkNodeFor = (site) => mintNode({
579
690
  kind: site.decision.kind, category: site.decision.category,
580
691
  coverageStatus: site.decision.coverageStatus, externality: site.decision.externality,
@@ -585,9 +696,15 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
585
696
  storeDetail: site.storeDetail ?? null,
586
697
  queueDetail: site.queueDetail ?? null,
587
698
  });
588
- const mintDataElement = (s) => {
589
- let d = deById.get(s.dataElementId);
699
+ const mintDataElement = (seedIn) => {
700
+ let d = deById.get(seedIn.dataElementId);
590
701
  if (!d) {
702
+ // GA-03.3: an element is named by its collection point, never by the
703
+ // key a handoff carried it under (`{contact: email}` is still the
704
+ // element `email` at the consumer). The first non-boundary seed of
705
+ // the element is that collection point; a boundary seed is the
706
+ // fallback only when nothing else minted the element.
707
+ const s = (seedSitesByDe.get(seedIn.dataElementId) ?? []).find((x) => !x.boundary) ?? seedIn;
591
708
  d = {
592
709
  id: s.dataElementId, name: s.canonicalName, aliases: [], declaredType: null,
593
710
  dataClasses: s.dataClasses, aiContexts: [], sourceLocations: [],
@@ -602,7 +719,6 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
602
719
  return d;
603
720
  };
604
721
 
605
- const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
606
722
  // E4's FR-203 closure hook (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): a
607
723
  // caller may substitute an adjusted decision for a site — e.g.
608
724
  // coverage.js's `resolveSiteDecision`, which reclassifies a sink whose
@@ -654,7 +770,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
654
770
  if (r.truncated) stats.truncatedQueries += 1;
655
771
  stats.pathsEnumerated += r.enumeratedPathCount;
656
772
  for (const p of r.paths) {
657
- const seed = seedByDe.get(p.dataElementId);
773
+ const seed = seedForPath(p);
658
774
  if (!seed) continue;
659
775
  connected = true;
660
776
  stats.pathsProjected += 1;
@@ -745,13 +861,18 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
745
861
  id: edgeIdStr, from: src.id, to: snk.id, relationship: 'data_flow',
746
862
  fieldMappings: [{ fromPath, toPath, dataElementIds: [de.id], mappingType, transformationIds: sortedT }],
747
863
  protocol: { name: 'in-process', destinationResolution: site.destination?.resolutionStatus ?? 'unknown' },
748
- boundaryCrossings: [],
864
+ // GA-03.3: an edge from a handoff seed records the crossing it
865
+ // rests on. The connection is modeled (a declared contract or a
866
+ // literal identity both sides name), never observed.
867
+ boundaryCrossings: seed.boundary
868
+ ? [{ kind: seed.boundary.kind, identity: seed.boundary.identity, joinId: seed.boundary.joinId, contractId: seed.boundary.contractId, basis: seed.boundary.basis, modeledInput: true, observed: false }]
869
+ : [],
749
870
  // Milestone 2, Sub-project F, increment 1 (FR-304): which
750
- // mechanism discovered this edge. Every edge minted here today
751
- // is genuinely code-derived — this is an unconditional, honest
752
- // literal, never a guess (see schema.js's EDGE_PROVENANCE_VALUES
753
- // comment for the full rationale).
754
- provenance: 'code',
871
+ // mechanism discovered this edge. An edge minted from code is
872
+ // `code`; an edge a boundary contract declared is `manual`
873
+ // (GA-03.3). Never a guess (see schema.js's
874
+ // EDGE_PROVENANCE_VALUES comment for the full rationale).
875
+ provenance: seed.boundary ? seed.boundary.provenance : 'code',
755
876
  // Milestone 2, Sub-project B, increment 2 (FR-401):
756
877
  // `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade}
757
878
  // | undefined`, applied at this exact point — the same block
@@ -774,7 +895,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
774
895
  // key by design and therefore never splits a flow.
775
896
  const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
776
897
  if (!groupsByFlowKey.has(flowKey)) groupsByFlowKey.set(flowKey, []);
777
- groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons, result: r });
898
+ groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, seed, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons, result: r });
778
899
  }
779
900
  }
780
901
  site.connected = connected;
@@ -811,6 +932,9 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
811
932
  ...(site.ambiguity && site.ambiguity.resolvedBy === 'plurality'
812
933
  ? [`sink category ambiguous at ${site.file}:${site.line}: also matches ${site.ambiguity.alternatives.join(', ')}`]
813
934
  : []),
935
+ ...(group[0].seed?.boundary
936
+ ? [`crosses a ${group[0].seed.boundary.kind} handoff (${group[0].seed.boundary.provenance === 'manual' ? `contract ${group[0].seed.boundary.contractId}` : `literal identity ${group[0].seed.boundary.identity}`}) that is modeled, not observed: no message or row was seen`]
937
+ : []),
814
938
  ])].sort();
815
939
  // Milestone 2, Sub-project D, increment 1 (FR-403): computed once,
816
940
  // here, from this flow's own representative reconstructed path — never
@@ -1169,11 +1293,17 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1169
1293
  ...(store.correlationVersion() === 2 ? { correlationVersion: 2 } : null),
1170
1294
  },
1171
1295
  ...(analysisCompletion.status === 'complete' ? {} : { analysisCompletion }),
1296
+ ...(boundaries ? { boundaries: boundaryCoverage(boundaries) } : {}),
1172
1297
  };
1173
1298
  graph.limitations = [
1174
1299
  'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
1175
1300
  'Policy verdicts are not evaluated in Milestone 1.',
1176
1301
  'External destinations are not resolved (FR-202 is Milestone 2); protocol.destinationResolution is "unknown" on every edge.',
1302
+ // GA-03.3: only a build that opted in says anything here, so a build
1303
+ // that did not is byte-identical to the build before joins existed.
1304
+ ...(boundaries
1305
+ ? [`Cross-service handoffs are joined from a declared boundary contract or from the same literal queue or collection identity on both sides (${boundaries.joins.length} join(s), ${boundaries.unjoined.length} endpoint(s) left unjoined with a reason); every join is modeled from code, never an observed message or row.`]
1306
+ : []),
1177
1307
  ];
1178
1308
  graph.scanHealth = analysisCompletion.status === 'complete'
1179
1309
  ? { status: 'complete', reason: 'lineage-graph-builder', analysisCompletion }
@@ -1194,5 +1324,5 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1194
1324
  }
1195
1325
  graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
1196
1326
 
1197
- return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
1327
+ return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId, boundaries };
1198
1328
  }
@@ -70,6 +70,7 @@ import { loadObservations } from './observation-store.js';
70
70
  // (which owns only the REMOTE side).
71
71
  import { validateCrossRepoLink, CROSS_REPO_LINKS_FILENAME } from './cross-repo-link.js';
72
72
  import { contributeNixConfigLineage } from './nix-view.js';
73
+ import { BOUNDARY_CONTRACTS_FILENAME } from './boundary-contract.js';
73
74
  import { adaptCallGraphForLineage } from './haskell-view.js';
74
75
 
75
76
  // A small, LOCAL, tolerant loader for the operator-declared
@@ -83,6 +84,31 @@ import { adaptCallGraphForLineage } from './haskell-view.js';
83
84
  // degrades to an empty/partial array with a console warning naming the
84
85
  // count skipped, mirroring `loadRecipientConfig`'s own per-entry
85
86
  // discipline.
87
+ /**
88
+ * GA-03.3: the DAG-08 contracts file, `[]` when absent or malformed (the
89
+ * parse error is printed, never thrown). Read directly and absence taken
90
+ * from the read's own error, so there is no existence check before the open.
91
+ */
92
+ function _loadBoundaryContracts(scanRoot) {
93
+ if (!scanRoot) return [];
94
+ const fp = statePath(scanRoot, BOUNDARY_CONTRACTS_FILENAME);
95
+ let raw;
96
+ try {
97
+ raw = fs.readFileSync(fp, 'utf8');
98
+ } catch (e) {
99
+ if (e && e.code === 'ENOENT') return [];
100
+ console.error(`agentic-security: cannot read boundary contracts file (${fp}); no declared handoffs will be joined (${e.message})`);
101
+ return [];
102
+ }
103
+ try {
104
+ const doc = JSON.parse(raw);
105
+ return Array.isArray(doc) ? doc : Array.isArray(doc && doc.contracts) ? doc.contracts : [];
106
+ } catch (e) {
107
+ console.error(`agentic-security: bad JSON in boundary contracts file (${fp}); no declared handoffs will be joined (${e.message})`);
108
+ return [];
109
+ }
110
+ }
111
+
86
112
  function _loadCrossRepoLinkRecords(filePath) {
87
113
  let raw;
88
114
  try {
@@ -262,8 +288,19 @@ export function buildLineageGraph(callGraph, opts = {}) {
262
288
  // Haskell is rewritten into a lineage VIEW (source reads become seedable member reads, sinks become
263
289
  // statement-level calls); every other language, and the shared call graph itself, are untouched.
264
290
  const lineageCallGraph = adaptCallGraphForLineage(callGraph);
291
+ // GA-03.3: cross-service handoff joins, opt-in (`opts.boundaries`,
292
+ // from AGENTIC_SECURITY_LINEAGE_BOUNDARIES=1 in engine.js). The
293
+ // operator's DAG-08 boundary contracts are read once here, from the
294
+ // same state directory `dataflow contracts` reads, existence-gated like
295
+ // every other config load in this function; a malformed file is a
296
+ // recorded failure of the join step (an empty contract list with the
297
+ // error named), never a thrown scan.
298
+ const boundaries = opts.boundaries && opts.boundaries.enabled
299
+ ? { enabled: true, contracts: opts.boundaries.contracts ?? _loadBoundaryContracts(opts.scanRoot) }
300
+ : undefined;
265
301
  const built = buildGraphWithCoverage(lineageCallGraph, {
266
302
  repository: opts.repository,
303
+ ...(boundaries ? { boundaries } : {}),
267
304
  generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
268
305
  perFile: opts.perFile,
269
306
  parseFailures: opts.parseFailures,
@@ -272,6 +272,13 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
272
272
 
273
273
  const MAX_ALIAS_ROUNDS = 4;
274
274
 
275
+ /** `JSON.parse(x)` / `JSON.stringify(x)` → x (one level); anything else unchanged. */
276
+ function unwrapJsonRoundTrip(e) {
277
+ if (e && e.kind === 'call' && e.callee && e.callee.kind === 'member' && (e.callee.prop === 'parse' || e.callee.prop === 'stringify')
278
+ && e.callee.object && e.callee.object.kind === 'ident' && e.callee.object.name === 'JSON' && Array.isArray(e.args) && e.args.length) return e.args[0];
279
+ return e;
280
+ }
281
+
275
282
  /** `(fn, fnSeeds, mint)`: see the comment at the call site in `planSeeds`. */
276
283
  function extendThroughAliases(fn, fnSeeds, mint) {
277
284
  const nodes = Object.entries(fn.cfg?.nodes ?? {});
@@ -293,7 +300,10 @@ function extendThroughAliases(fn, fnSeeds, mint) {
293
300
  for (const [, node] of nodes) {
294
301
  if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.') || node.target.includes('*')) continue;
295
302
  if (aliases.has(node.target)) continue;
296
- const r = resolve(accessPathOf(node.source));
303
+ // GA-03.3: a JSON round trip keeps the container's structure (the
304
+ // same rule engine.js applies), so `const s = JSON.stringify(req.body)`
305
+ // and `const back = JSON.parse(s)` are aliases of the container too.
306
+ const r = resolve(accessPathOf(unwrapJsonRoundTrip(node.source)));
297
307
  if (!r) continue;
298
308
  aliases.set(node.target, r);
299
309
  changed = true;