@clear-capabilities/agentic-security-scanner 0.195.23 → 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.
@@ -74,6 +74,13 @@ function isV2(ctx) {
74
74
  // the derivation path of the level that emitted it (the innermost stamp
75
75
  // wins, because an inner wrapper's fields are already on the hop when the
76
76
  // outer wrapper sees it).
77
+ /** `JSON.parse` / `JSON.stringify` (GA-03.3): the one call pair the resolver treats as structure-preserving. */
78
+ function isJsonRoundTrip(callee) {
79
+ if (typeof callee === 'string') return callee === 'JSON.parse' || callee === 'JSON.stringify';
80
+ return !!callee && callee.kind === 'member' && (callee.prop === 'parse' || callee.prop === 'stringify')
81
+ && callee.object?.kind === 'ident' && callee.object.name === 'JSON';
82
+ }
83
+
77
84
  function sub(ctx, seg, stamp) {
78
85
  if (!isV2(ctx)) return ctx;
79
86
  const derivation = deriveChild(ctx.derivation, seg);
@@ -610,6 +617,19 @@ export function resolveExprIdentities(state, expr, ctx) {
610
617
  return { flat, byPath, widened: false };
611
618
  }
612
619
  }
620
+ // GA-03.3 (0.195.25): a JSON round trip is structure-PRESERVING.
621
+ // `JSON.stringify(v)` carries exactly v's fields (serialized) and
622
+ // `JSON.parse(s)` yields exactly the fields s was serialized from, so
623
+ // both forward the first argument's `{flat, byPath, widened}`
624
+ // unchanged, the way `assign-expr` forwards its source: the field a
625
+ // queue consumer reads off `JSON.parse(m.Body)` keeps the identity the
626
+ // producer put under that key instead of collapsing to the whole
627
+ // record's flat union. The argument's own recursion emits its hops;
628
+ // nothing is recorded for the call itself (there is no new value).
629
+ // The replacer/reviver/space arguments never carry the value.
630
+ if (isJsonRoundTrip(expr.callee) && (expr.args ?? []).length >= 1) {
631
+ return resolveExprIdentities(state, expr.args[0], sub(ctx, 'a0'));
632
+ }
613
633
  const flat = new Set();
614
634
  // Hotfix (found during Sub-project E1's design spike; see
615
635
  // DESIGN_GRAPH_BUILDER.md §11): an unresolved method call's RECEIVER
@@ -923,6 +943,18 @@ function step(node, stateIn, widenings, ctx) {
923
943
  }
924
944
  }
925
945
  }
946
+ // Known, disclosed gap (GA-03.1, 0.195.24, measured): a bare call
947
+ // statement is never resolved through `ctx.resolveCallSummary`, so a
948
+ // sink INSIDE the callee (`emit(logger, req.body.card_number)` with
949
+ // `logger.info(v)` in emit) never sees the argument's identity; the
950
+ // value is an escape here and nothing more. Resolving it was tried
951
+ // and reverted in 0.195.24: the extra callee analysis consumes a
952
+ // summary context per call site, and under a tight context cap that
953
+ // degrades a later resolution to an empty summary (the §13.2a hazard
954
+ // of DESIGN_PATH_PROVENANCE.md, in the unsound direction), moving
955
+ // seven pinned provenance measurements. Closing it needs the cap to
956
+ // exempt bare-call contexts or a separate budget; pinned as a gap by
957
+ // test/lineage/seeding-across-calls.test.js (IP/2).
926
958
  return { state: stateIn, returnFact: null };
927
959
  }
928
960
 
@@ -1068,7 +1100,18 @@ export function analyzeFunctionFieldIdentity(fn, entryState, ctx) {
1068
1100
  recordHop: stepRecord,
1069
1101
  }
1070
1102
  : ctx;
1071
- const { state: out, returnFact } = step(node, incoming, widenings, stepCtx);
1103
+ let { state: out, returnFact } = step(node, incoming, widenings, stepCtx);
1104
+ // GA-03.1 (0.195.24): `after-node` seeds (source-seeding.js, SEED_KINDS).
1105
+ // A call-shaped source assigned to a variable produces its identity in
1106
+ // the TARGET once this node has run; the assignment's own strong update
1107
+ // has already cleared the target, so this is a plain add, idempotent on
1108
+ // a worklist revisit. Keyed by `(scope, nid)` so the same hook serves a
1109
+ // callee analysed through the call-summary resolver. No hop is recorded
1110
+ // here: the first read of the target creates the path node, which then
1111
+ // has no in-edges and reconstructs as an origin, exactly as an entry
1112
+ // seed's first read does.
1113
+ const after = ctx?.seedAfterNode ? ctx.seedAfterNode(scope, nid) : undefined;
1114
+ if (after) for (const s of after) out = addIdentity(out, s.path, s.id);
1072
1115
  if (returnFact && returnFact.size > 0) {
1073
1116
  // Union onto any existing fact for this node rather than pushing a
1074
1117
  // new array entry every visit — see Fix 2 in the final whole-branch
@@ -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
@@ -131,7 +132,7 @@ import { classifyHandling } from './handling-analyzer.js';
131
132
  import { emptyGraphEnvelope } from './schema.js';
132
133
  import { emptyProtection, aggregateVerdicts } from './protection.js';
133
134
  import * as ids from './ids.js';
134
- import { planSeeds, seedEntryStateFactory, exprRoots, walkExpr } from './source-seeding.js';
135
+ import { planSeeds, seedEntryStateFactory, seedAfterNodeFactory, exprRoots, walkExpr } from './source-seeding.js';
135
136
  import { RECIPIENT_FACT_FIELDS } from './recipient-profile.js';
136
137
  import { pathEvidenceEnabled, evaluatePathEvidence, summarizeFlowEvidence } from './path-evidence.js';
137
138
  import { sinkFamilyOf } from './control-credit.js';
@@ -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,18 +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
- ...(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 }),
459
- ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
460
- });
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
+ }
461
545
  const store = new PathStore({}, { correlationVersion: opts.correlationVersion });
462
546
  store.addHops(hops);
463
547
  // DAG-07 R07.2: an analysis unit that hit its budget has no hop
@@ -480,6 +564,10 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
480
564
  for (const u of analysisCompletion.incomplete) {
481
565
  store.markTruncated(u.qid, u.context, u.reasons[0] ?? 'analysis-incomplete');
482
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
+ }
483
571
 
484
572
  const cfgByQid = new Map();
485
573
  for (const fn of callGraph.functions.values()) cfgByQid.set(fn.qid, { fn, nodes: fn.cfg?.nodes ?? {} });
@@ -491,6 +579,20 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
491
579
  if (!seedSitesByDe.has(s.dataElementId)) seedSitesByDe.set(s.dataElementId, []);
492
580
  seedSitesByDe.get(s.dataElementId).push(s);
493
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);
494
596
 
495
597
  const graph = emptyGraphEnvelope({
496
598
  graphId: ids.graphId({ repository, commit: opts.commit ?? 'uncommitted', configHash: opts.configHash ?? 'default' }),
@@ -568,11 +670,22 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
568
670
  decisionsByNodeId.get(id).push({ kind, category, coverageStatus, externality });
569
671
  return n;
570
672
  };
571
- const sourceNodeFor = (s) => mintNode({
572
- kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
573
- coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
574
- lifecycleStages: ['collection'],
575
- });
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
+ };
576
689
  const sinkNodeFor = (site) => mintNode({
577
690
  kind: site.decision.kind, category: site.decision.category,
578
691
  coverageStatus: site.decision.coverageStatus, externality: site.decision.externality,
@@ -583,9 +696,15 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
583
696
  storeDetail: site.storeDetail ?? null,
584
697
  queueDetail: site.queueDetail ?? null,
585
698
  });
586
- const mintDataElement = (s) => {
587
- let d = deById.get(s.dataElementId);
699
+ const mintDataElement = (seedIn) => {
700
+ let d = deById.get(seedIn.dataElementId);
588
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;
589
708
  d = {
590
709
  id: s.dataElementId, name: s.canonicalName, aliases: [], declaredType: null,
591
710
  dataClasses: s.dataClasses, aiContexts: [], sourceLocations: [],
@@ -600,7 +719,6 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
600
719
  return d;
601
720
  };
602
721
 
603
- const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
604
722
  // E4's FR-203 closure hook (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): a
605
723
  // caller may substitute an adjusted decision for a site — e.g.
606
724
  // coverage.js's `resolveSiteDecision`, which reclassifies a sink whose
@@ -652,7 +770,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
652
770
  if (r.truncated) stats.truncatedQueries += 1;
653
771
  stats.pathsEnumerated += r.enumeratedPathCount;
654
772
  for (const p of r.paths) {
655
- const seed = seedByDe.get(p.dataElementId);
773
+ const seed = seedForPath(p);
656
774
  if (!seed) continue;
657
775
  connected = true;
658
776
  stats.pathsProjected += 1;
@@ -743,13 +861,18 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
743
861
  id: edgeIdStr, from: src.id, to: snk.id, relationship: 'data_flow',
744
862
  fieldMappings: [{ fromPath, toPath, dataElementIds: [de.id], mappingType, transformationIds: sortedT }],
745
863
  protocol: { name: 'in-process', destinationResolution: site.destination?.resolutionStatus ?? 'unknown' },
746
- 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
+ : [],
747
870
  // Milestone 2, Sub-project F, increment 1 (FR-304): which
748
- // mechanism discovered this edge. Every edge minted here today
749
- // is genuinely code-derived — this is an unconditional, honest
750
- // literal, never a guess (see schema.js's EDGE_PROVENANCE_VALUES
751
- // comment for the full rationale).
752
- 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',
753
876
  // Milestone 2, Sub-project B, increment 2 (FR-401):
754
877
  // `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade}
755
878
  // | undefined`, applied at this exact point — the same block
@@ -772,7 +895,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
772
895
  // key by design and therefore never splits a flow.
773
896
  const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
774
897
  if (!groupsByFlowKey.has(flowKey)) groupsByFlowKey.set(flowKey, []);
775
- 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 });
776
899
  }
777
900
  }
778
901
  site.connected = connected;
@@ -809,6 +932,9 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
809
932
  ...(site.ambiguity && site.ambiguity.resolvedBy === 'plurality'
810
933
  ? [`sink category ambiguous at ${site.file}:${site.line}: also matches ${site.ambiguity.alternatives.join(', ')}`]
811
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
+ : []),
812
938
  ])].sort();
813
939
  // Milestone 2, Sub-project D, increment 1 (FR-403): computed once,
814
940
  // here, from this flow's own representative reconstructed path — never
@@ -1167,11 +1293,17 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1167
1293
  ...(store.correlationVersion() === 2 ? { correlationVersion: 2 } : null),
1168
1294
  },
1169
1295
  ...(analysisCompletion.status === 'complete' ? {} : { analysisCompletion }),
1296
+ ...(boundaries ? { boundaries: boundaryCoverage(boundaries) } : {}),
1170
1297
  };
1171
1298
  graph.limitations = [
1172
1299
  'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
1173
1300
  'Policy verdicts are not evaluated in Milestone 1.',
1174
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
+ : []),
1175
1307
  ];
1176
1308
  graph.scanHealth = analysisCompletion.status === 'complete'
1177
1309
  ? { status: 'complete', reason: 'lineage-graph-builder', analysisCompletion }
@@ -1192,5 +1324,5 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1192
1324
  }
1193
1325
  graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
1194
1326
 
1195
- 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 };
1196
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,
@@ -0,0 +1,143 @@
1
+ //
2
+ // lineage-source-catalog.js — JavaScript data collection points that are
3
+ // lineage sources but NOT taint sources (GA plan, GA-03.1: "address
4
+ // source-category omissions without broadening the main taint catalog
5
+ // incorrectly").
6
+ //
7
+ // Four of the fourteen reachable `SOURCE_CATEGORIES` had no `language: 'js'`
8
+ // entry in `dataflow/catalog.js` at all: `cli-argument` (only `py-sys-argv`
9
+ // and the argparse entries), `http-upload` (only Django and Symfony),
10
+ // `storage-read` (only `py-open-read` and the C `read`/`fread`/`fgets`
11
+ // entries) and `user-input` (only `py-input`, `gets`, `scanf`). The lineage
12
+ // corpus therefore could not mint a source node for any of them from a
13
+ // JavaScript fixture, and the four corpus entries that name them were
14
+ // written in Python syntax on purpose, as a disclosed limitation.
15
+ //
16
+ // Why these entries live HERE and not in `dataflow/catalog.js`'s `CATALOG`:
17
+ // that catalog is the taint engine's source list, and every entry in it
18
+ // becomes an attacker-controlled input for every SAST and taint finding the
19
+ // engine emits. A file's contents (`fs.readFileSync`) and a console prompt
20
+ // (`prompt()`, `readlineSync.question()`) are places personal data ENTERS a
21
+ // program, which is what a lineage graph records, but treating them as
22
+ // attacker-controlled would turn every configuration read into a taint
23
+ // source and flood the SAST output with findings nobody asked for. The
24
+ // Data Flow Explorer already keeps a second, isolated catalog for the same
25
+ // reason (`dataflow/privacy-catalog.js`, never merged into `CATALOG`); this
26
+ // module follows that precedent on the source side. `test/lineage/
27
+ // lineage-source-catalog.test.js` pins that `dataflow/catalog.js`'s own
28
+ // `matchSource` returns null for every shape listed here, so adding an
29
+ // entry here can never widen the taint engine by accident.
30
+ //
31
+ // Reuse boundary: imports nothing from `dataflow/`. The matcher below is a
32
+ // deliberately small structural match over the parser's expression shape
33
+ // (`ident`/`member`/`call`), not a copy of `catalog.js`'s index machinery;
34
+ // an entry that needs receiver types, keyword arguments or a language family
35
+ // other than JavaScript belongs in the main catalog, with the taint
36
+ // consequences weighed there.
37
+
38
+ import { reclassifySource } from './source-registry.js';
39
+
40
+ const JS_FILE = /\.(?:[cm]?js|jsx|tsx?)$/i;
41
+
42
+ /**
43
+ * Each entry mirrors the main catalog's source shape (`kind`, `id`,
44
+ * `language`, `framework`, `match`, `label`, `provenance`) so
45
+ * `reclassifySource` reads it unchanged: `provenance` is the key the
46
+ * registry's `PROVENANCE_MAP` resolves to a `SOURCE_CATEGORIES` value.
47
+ * `lineageOnly: true` is the marker a reader greps for; nothing consults it.
48
+ */
49
+ export const LINEAGE_SOURCE_CATALOG = Object.freeze([
50
+ {
51
+ kind: 'source', id: 'js-process-argv', language: 'js', framework: 'node', lineageOnly: true,
52
+ match: { type: 'member', object: 'process', prop: 'argv' }, label: 'process.argv', provenance: 'cli',
53
+ why: 'a positional command-line argument; the value is whatever the operator typed, not attacker-controlled in the taint sense, so it is a collection point for lineage only',
54
+ },
55
+ {
56
+ kind: 'source', id: 'js-multer-req-file', language: 'js', framework: 'express', lineageOnly: true,
57
+ match: { type: 'member', object: 'req', prop: 'file' }, label: 'req.file', provenance: 'upload',
58
+ why: 'the single uploaded file multer attaches to the request (name, type, buffer, path)',
59
+ },
60
+ {
61
+ kind: 'source', id: 'js-multer-req-files', language: 'js', framework: 'express', lineageOnly: true,
62
+ match: { type: 'member', object: 'req', prop: 'files' }, label: 'req.files', provenance: 'upload',
63
+ why: 'the uploaded files multer attaches to the request, keyed by field name',
64
+ },
65
+ {
66
+ kind: 'source', id: 'js-fs-readFileSync', language: 'js', framework: 'node', lineageOnly: true,
67
+ match: { type: 'call', callee: 'readFileSync' }, label: 'fs.readFileSync()', provenance: 'file-read',
68
+ why: 'the contents of a file; the callback form `fs.readFile(p, cb)` delivers its contents to a callback and is not seeded (its path argument is already a sink in the main catalog)',
69
+ },
70
+ {
71
+ kind: 'source', id: 'js-window-prompt', language: 'js', framework: 'browser', lineageOnly: true,
72
+ match: { type: 'call', callee: 'prompt', bare: true }, label: 'prompt()', provenance: 'stdin',
73
+ why: 'text a person typed into the browser prompt dialog; only the bare global, never `x.prompt()` (inquirer and friends return a promise of an answers object)',
74
+ },
75
+ {
76
+ kind: 'source', id: 'js-readline-sync-question', language: 'js', framework: 'node', lineageOnly: true,
77
+ match: { type: 'call', callee: 'question', receiver: 'readlineSync' }, label: 'readlineSync.question()', provenance: 'stdin',
78
+ why: 'text a person typed at the console; the readline module\'s own `rl.question(q, cb)` delivers the answer to a callback and is not seeded',
79
+ },
80
+ ]);
81
+
82
+ /** The receiver chain of a member-call callee, outermost first (`a.b.c()` -> ['a', 'b']). */
83
+ function receiverSegments(callee) {
84
+ const segs = [];
85
+ let cur = callee && callee.kind === 'member' ? callee.object : null;
86
+ let depth = 0;
87
+ while (cur && depth++ < 8) {
88
+ if (cur.kind === 'ident') { segs.unshift(cur.name); break; }
89
+ if (cur.kind === 'member') { if (typeof cur.prop === 'string') segs.unshift(cur.prop); cur = cur.object; continue; }
90
+ break;
91
+ }
92
+ return segs;
93
+ }
94
+
95
+ function calleeName(callee) {
96
+ if (!callee) return null;
97
+ if (typeof callee === 'string') return callee.slice(callee.lastIndexOf('.') + 1);
98
+ if (callee.kind === 'ident') return callee.name ?? null;
99
+ if (callee.kind === 'member') return typeof callee.prop === 'string' ? callee.prop : null;
100
+ return null;
101
+ }
102
+
103
+ function entryMatches(entry, expr) {
104
+ const m = entry.match;
105
+ if (m.type === 'member') {
106
+ return expr.kind === 'member' && expr.object?.kind === 'ident' && expr.object.name === m.object && expr.prop === m.prop;
107
+ }
108
+ if (m.type === 'call') {
109
+ if (expr.kind !== 'call') return false;
110
+ if (calleeName(expr.callee) !== m.callee) return false;
111
+ if (m.bare) return expr.callee && expr.callee.kind === 'ident';
112
+ if (m.receiver) return receiverSegments(expr.callee).includes(m.receiver);
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+
118
+ /**
119
+ * The lineage-only counterpart of `dataflow/catalog.js`'s `matchSource`:
120
+ * returns the first catalog entry whose `match` fits `expr`, or null.
121
+ * Gated to JavaScript and TypeScript files (an entry here is JavaScript by
122
+ * construction); a missing `file` is permissive, matching `matchSource`.
123
+ * Never throws on a malformed expression.
124
+ */
125
+ export function matchLineageSource(expr, file) {
126
+ if (!expr || typeof expr !== 'object') return null;
127
+ if (file && !JS_FILE.test(file)) return null;
128
+ for (const entry of LINEAGE_SOURCE_CATALOG) {
129
+ if (entryMatches(entry, expr)) return entry;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ /** The registry decision for every entry, computed once: proves at load time that each `provenance` resolves. */
135
+ export const LINEAGE_SOURCE_DECISIONS = Object.freeze(Object.fromEntries(
136
+ LINEAGE_SOURCE_CATALOG.map((e) => {
137
+ const d = reclassifySource(e);
138
+ if (!d || !d.category || d.coverageStatus === 'unsupported') {
139
+ throw new Error(`lineage-source-catalog: ${e.id} has a provenance (${e.provenance}) the source registry cannot classify`);
140
+ }
141
+ return [e.id, d];
142
+ }),
143
+ ));