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

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' }),
@@ -502,6 +602,14 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
502
602
  // module ever passed `undefined`/omitted the key entirely).
503
603
  generatedAt: opts.generatedAt ?? '1970-01-01T00:00:00.000Z',
504
604
  });
605
+ // GA-03.7 (0.195.26): the revision the graph was built from, when the
606
+ // caller knows it (a scan passes the repository head). A consumer can then
607
+ // refuse a graph built from another revision (`loadSignedGraph`'s
608
+ // `expectRevision`). Absent when no commit was given, so a build without
609
+ // one is byte-identical to before.
610
+ if (typeof opts.commit === 'string' && opts.commit) {
611
+ graph.scope.revision = { commit: opts.commit, worktreeDirty: typeof opts.worktreeDirty === 'boolean' ? opts.worktreeDirty : null };
612
+ }
505
613
 
506
614
  const nodesById = new Map();
507
615
  const edgesById = new Map();
@@ -570,11 +678,22 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
570
678
  decisionsByNodeId.get(id).push({ kind, category, coverageStatus, externality });
571
679
  return n;
572
680
  };
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
- });
681
+ // GA-03.3: a seed a handoff minted has no collection point of its own; its
682
+ // source node IS the producer's sink node (the queue or the store), so the
683
+ // graph reads as one chain, HTTP to queue to store to HTTP response, with
684
+ // the handoff visible on the edge. The producer site always exists (a join
685
+ // is planned from a site in `sites`); the fallback below is defensive.
686
+ const sourceNodeFor = (s) => {
687
+ if (s.boundary) {
688
+ const psite = siteByKey.get(`${s.boundary.producer.qid}|${s.boundary.producer.nodeId}`);
689
+ if (psite) return sinkNodeFor(psite);
690
+ }
691
+ return mintNode({
692
+ kind: 'source', category: s.category, coverageStatus: s.coverageStatus, externality: s.externality,
693
+ coverageReason: s.reason, subtypeKey: s.category ?? `unsupported-source:${s.entryId}`,
694
+ lifecycleStages: ['collection'],
695
+ });
696
+ };
578
697
  const sinkNodeFor = (site) => mintNode({
579
698
  kind: site.decision.kind, category: site.decision.category,
580
699
  coverageStatus: site.decision.coverageStatus, externality: site.decision.externality,
@@ -585,9 +704,15 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
585
704
  storeDetail: site.storeDetail ?? null,
586
705
  queueDetail: site.queueDetail ?? null,
587
706
  });
588
- const mintDataElement = (s) => {
589
- let d = deById.get(s.dataElementId);
707
+ const mintDataElement = (seedIn) => {
708
+ let d = deById.get(seedIn.dataElementId);
590
709
  if (!d) {
710
+ // GA-03.3: an element is named by its collection point, never by the
711
+ // key a handoff carried it under (`{contact: email}` is still the
712
+ // element `email` at the consumer). The first non-boundary seed of
713
+ // the element is that collection point; a boundary seed is the
714
+ // fallback only when nothing else minted the element.
715
+ const s = (seedSitesByDe.get(seedIn.dataElementId) ?? []).find((x) => !x.boundary) ?? seedIn;
591
716
  d = {
592
717
  id: s.dataElementId, name: s.canonicalName, aliases: [], declaredType: null,
593
718
  dataClasses: s.dataClasses, aiContexts: [], sourceLocations: [],
@@ -602,7 +727,6 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
602
727
  return d;
603
728
  };
604
729
 
605
- const { sites, nonStatementSites } = enumerateSinkSites(callGraph);
606
730
  // E4's FR-203 closure hook (DESIGN_GRAPH_BUILDER.md §9.4 item 5b): a
607
731
  // caller may substitute an adjusted decision for a site — e.g.
608
732
  // coverage.js's `resolveSiteDecision`, which reclassifies a sink whose
@@ -654,7 +778,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
654
778
  if (r.truncated) stats.truncatedQueries += 1;
655
779
  stats.pathsEnumerated += r.enumeratedPathCount;
656
780
  for (const p of r.paths) {
657
- const seed = seedByDe.get(p.dataElementId);
781
+ const seed = seedForPath(p);
658
782
  if (!seed) continue;
659
783
  connected = true;
660
784
  stats.pathsProjected += 1;
@@ -745,13 +869,18 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
745
869
  id: edgeIdStr, from: src.id, to: snk.id, relationship: 'data_flow',
746
870
  fieldMappings: [{ fromPath, toPath, dataElementIds: [de.id], mappingType, transformationIds: sortedT }],
747
871
  protocol: { name: 'in-process', destinationResolution: site.destination?.resolutionStatus ?? 'unknown' },
748
- boundaryCrossings: [],
872
+ // GA-03.3: an edge from a handoff seed records the crossing it
873
+ // rests on. The connection is modeled (a declared contract or a
874
+ // literal identity both sides name), never observed.
875
+ boundaryCrossings: seed.boundary
876
+ ? [{ kind: seed.boundary.kind, identity: seed.boundary.identity, joinId: seed.boundary.joinId, contractId: seed.boundary.contractId, basis: seed.boundary.basis, modeledInput: true, observed: false }]
877
+ : [],
749
878
  // 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',
879
+ // mechanism discovered this edge. An edge minted from code is
880
+ // `code`; an edge a boundary contract declared is `manual`
881
+ // (GA-03.3). Never a guess (see schema.js's
882
+ // EDGE_PROVENANCE_VALUES comment for the full rationale).
883
+ provenance: seed.boundary ? seed.boundary.provenance : 'code',
755
884
  // Milestone 2, Sub-project B, increment 2 (FR-401):
756
885
  // `opts.resolveTransitProtection(site) -> {verdict, evidenceGrade}
757
886
  // | undefined`, applied at this exact point — the same block
@@ -774,7 +903,7 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
774
903
  // key by design and therefore never splits a flow.
775
904
  const flowKey = [src.id, snk.id, de.id, p.shape, g.grade, sortedT.join(',')].join('|');
776
905
  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 });
906
+ groupsByFlowKey.get(flowKey).push({ p, g, src, snk, de, seed, edgeIdStr, sortedT, site, unattributed, truncated: r.truncated, truncationReasons: r.truncationReasons, result: r });
778
907
  }
779
908
  }
780
909
  site.connected = connected;
@@ -811,6 +940,9 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
811
940
  ...(site.ambiguity && site.ambiguity.resolvedBy === 'plurality'
812
941
  ? [`sink category ambiguous at ${site.file}:${site.line}: also matches ${site.ambiguity.alternatives.join(', ')}`]
813
942
  : []),
943
+ ...(group[0].seed?.boundary
944
+ ? [`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`]
945
+ : []),
814
946
  ])].sort();
815
947
  // Milestone 2, Sub-project D, increment 1 (FR-403): computed once,
816
948
  // here, from this flow's own representative reconstructed path — never
@@ -1169,11 +1301,17 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1169
1301
  ...(store.correlationVersion() === 2 ? { correlationVersion: 2 } : null),
1170
1302
  },
1171
1303
  ...(analysisCompletion.status === 'complete' ? {} : { analysisCompletion }),
1304
+ ...(boundaries ? { boundaries: boundaryCoverage(boundaries) } : {}),
1172
1305
  };
1173
1306
  graph.limitations = [
1174
1307
  'Protection verdicts are not assessed in Milestone 1: every edge carries not_assessed/none on all three dimensions.',
1175
1308
  'Policy verdicts are not evaluated in Milestone 1.',
1176
1309
  'External destinations are not resolved (FR-202 is Milestone 2); protocol.destinationResolution is "unknown" on every edge.',
1310
+ // GA-03.3: only a build that opted in says anything here, so a build
1311
+ // that did not is byte-identical to the build before joins existed.
1312
+ ...(boundaries
1313
+ ? [`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.`]
1314
+ : []),
1177
1315
  ];
1178
1316
  graph.scanHealth = analysisCompletion.status === 'complete'
1179
1317
  ? { status: 'complete', reason: 'lineage-graph-builder', analysisCompletion }
@@ -1194,5 +1332,5 @@ export function buildDataFlowGraph(callGraph, opts = {}) {
1194
1332
  }
1195
1333
  graph.taxonomy = { version: '1.0.0', source: 'built-in + CONFIDENTIAL extension' };
1196
1334
 
1197
- return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId };
1335
+ return { graph, store, hops, analysisCompletion, seeds, unseedable, sites, nonStatementSites, degraded, stats, decisionsByNodeId, boundaries };
1198
1336
  }
@@ -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,21 @@ 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 } : {}),
304
+ // GA-03.7: the scanned revision, recorded in `graph.scope.revision` and in the graph id.
305
+ ...(typeof opts.commit === 'string' && opts.commit ? { commit: opts.commit, worktreeDirty: opts.worktreeDirty } : {}),
267
306
  generatedAt: opts.deterministic ? undefined : new Date().toISOString(),
268
307
  perFile: opts.perFile,
269
308
  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;
@@ -79,7 +79,7 @@ async function loadProgram(root) {
79
79
  // ---------------------------------------------------------------------------------------------------------------------
80
80
 
81
81
  /**
82
- * agentic-security dataflow view [path] [--node <id> [--direction in|out|both] [--relations a,b] [--origins a,b]] [--output <file>]
82
+ * agentic-security dataflow view [path] [--node <id> [--direction in|out|both] [--relations a,b] [--origins a,b]] [--expect-revision <sha|HEAD>] [--output <file>]
83
83
  * Read-only over the signed lineage graph. Without --node it reports the view identity, counts, completion and the SCC
84
84
  * condensation of the data-flow relation. With --node it lists the neighbours the filter allows.
85
85
  */
@@ -95,7 +95,18 @@ export async function cmdDataflowView(args) {
95
95
  if (f.node === undefined && (f.direction || f.relations || f.origins)) { err(`${who}: --direction, --relations and --origins apply to --node.`); return 2; }
96
96
 
97
97
  const { loadSignedGraph } = await import('../server/graph-loader.js');
98
- const loaded = loadSignedGraph(root);
98
+ // GA-03.7: `--expect-revision <sha|HEAD>` refuses a graph built from another revision (or from none).
99
+ let expectRevision = null;
100
+ if (f['expect-revision'] !== undefined) {
101
+ if (typeof f['expect-revision'] !== 'string' || !f['expect-revision']) { err(`${who}: --expect-revision needs a commit or HEAD.`); return 2; }
102
+ if (f['expect-revision'] === 'HEAD') {
103
+ const { getRepoState } = await import('./provenance/git-evidence.js');
104
+ const st = getRepoState(root);
105
+ if (!st || !st.head) { err(`${who}: --expect-revision HEAD needs a git repository at ${root}.`); return 2; }
106
+ expectRevision = st.head;
107
+ } else expectRevision = f['expect-revision'];
108
+ }
109
+ const loaded = loadSignedGraph(root, { expectRevision });
99
110
  if (!loaded.ok) { err(`${who}: ${loaded.message}`); return 1; }
100
111
  const { dataflowView, GraphViewError } = await import('../graph/index.js');
101
112
  const { computeScc, verifyScc } = await import('../graph/scc.js');
@@ -107,6 +118,8 @@ export async function cmdDataflowView(args) {
107
118
  schema: 'agentic-security/graph-view-report@1',
108
119
  maturity: 'preview',
109
120
  identity: view.identity,
121
+ revision: loaded.graph.scope && loaded.graph.scope.revision ? loaded.graph.scope.revision : null,
122
+ expectedRevision: expectRevision,
110
123
  metadata: view.metadata(),
111
124
  viewCompletion: view.completion(),
112
125
  note: 'A view is a read-only lens over the signed graph. An absent edge means the model has no such edge, not that the software has none.',
@@ -1,4 +1,5 @@
1
1
  import * as cp from 'node:child_process';
2
+ import { STATE_DIR_NAME } from '../state-dir.js';
2
3
  import * as fs from 'node:fs';
3
4
  import * as path from 'node:path';
4
5
  import { hardenGitArgs, hardenGitEnv } from '../../util/git-hardening.js';
@@ -95,6 +96,26 @@ export function getRemoteUrl(scanRoot) {
95
96
  return r.ok ? r.stdout.trim() : null;
96
97
  }
97
98
 
99
+ /**
100
+ * Whether the working tree has changes OUTSIDE the given path prefixes (default: the scanner's own state
101
+ * directory). `getRepoState().dirty` counts the scanner's own untracked state directory as dirty, which would make the
102
+ * lineage graph's recorded revision flip between two scans of an unchanged tree merely because the first
103
+ * scan created that directory. Null when not a git repository or when git fails.
104
+ */
105
+ export function worktreeDirtyExcluding(scanRoot, ignorePrefixes = [`${STATE_DIR_NAME}/`]) {
106
+ if (!isGitRepo(scanRoot)) return null;
107
+ const r = _run(scanRoot, ['status', '--porcelain', '--untracked-files=all']);
108
+ if (!r.ok) return null;
109
+ for (const line of r.stdout.split('\n')) {
110
+ if (!line.trim()) continue;
111
+ const p = line.slice(3).replace(/^"|"$/g, '');
112
+ const renamedTo = p.includes(' -> ') ? p.split(' -> ')[1] : p;
113
+ if (ignorePrefixes.some((pre) => p.startsWith(pre) || renamedTo.startsWith(pre))) continue;
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+
98
119
  export function getRepoState(scanRoot) {
99
120
  if (!isGitRepo(scanRoot)) return null;
100
121
  const head = _run(scanRoot, ['rev-parse', 'HEAD']);
@@ -19,7 +19,7 @@ import { verifyLastScan } from '../posture/integrity.js';
19
19
 
20
20
  /**
21
21
  * @param {string} scanRoot
22
- * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
22
+ * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed'|'wrong-revision', message:string}}
23
23
  *
24
24
  * Four, and only four, distinct failure reasons — each with its own clear
25
25
  * message so an operator knows exactly what to do next:
@@ -36,7 +36,7 @@ import { verifyLastScan } from '../posture/integrity.js';
36
36
  * valid JSON. Should not happen from a normal scan; the
37
37
  * file may be corrupted on disk after signing.
38
38
  */
39
- export function loadSignedGraph(scanRoot) {
39
+ export function loadSignedGraph(scanRoot, { expectRevision = null } = {}) {
40
40
  const graphPath = statePath(scanRoot, 'lineage-graph.json');
41
41
  const sigPath = graphPath + '.sig';
42
42
 
@@ -86,6 +86,23 @@ export function loadSignedGraph(scanRoot) {
86
86
  };
87
87
  }
88
88
 
89
+ // GA-03.7 (0.195.26): wrong-revision refusal. A caller that knows which
90
+ // revision it expects (`dataflow view --expect-revision`) gets a graph
91
+ // only when the graph records that revision; a graph that records none
92
+ // cannot prove it and is refused for the same reason, never served on
93
+ // the hope that it matches.
94
+ if (expectRevision) {
95
+ const have = graph && graph.scope && graph.scope.revision ? graph.scope.revision.commit : null;
96
+ if (have !== expectRevision) {
97
+ return {
98
+ ok: false,
99
+ reason: 'wrong-revision',
100
+ message: have
101
+ ? `Lineage graph at ${graphPath} was built from revision ${have}, not the expected ${expectRevision}. Re-run the scan on the expected revision.`
102
+ : `Lineage graph at ${graphPath} records no revision, so it cannot be shown to match the expected ${expectRevision}. Re-run the scan inside the repository (AGENTIC_SECURITY_LINEAGE_DEEP=1) so the graph records its revision.`,
103
+ };
104
+ }
105
+ }
89
106
  return { ok: true, graph };
90
107
  }
91
108