@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.
- package/CHANGELOG.md +32 -0
- package/bin/agentic-security.js +9 -0
- package/dist/5752.index.js +16 -3
- package/dist/{daefda0819bb18309b75.json → 60f58f89664e53fc2f96.json} +3 -3
- package/dist/8218.index.js +19 -2
- package/dist/9382.index.js +315 -174
- package/dist/9560.index.js +19 -2
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/context-intelligence/CLAUDE.md +4 -0
- package/src/context-intelligence/entrypoints.js +141 -9
- package/src/context-intelligence/imports.js +1 -1
- package/src/context-intelligence/review.js +8 -3
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/engine.js +13 -0
- package/src/lineage/CLAUDE.md +17 -0
- package/src/lineage/boundary-join.js +384 -0
- package/src/lineage/coverage.js +4 -1
- package/src/lineage/engine.js +20 -0
- package/src/lineage/graph-builder.js +172 -34
- package/src/lineage/index.js +39 -0
- package/src/lineage/source-seeding.js +11 -1
- package/src/posture/dag-commands.js +15 -2
- package/src/posture/provenance/git-evidence.js +21 -0
- package/src/server/graph-loader.js +19 -2
|
@@ -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
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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 = (
|
|
589
|
-
let d = deById.get(
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
751
|
-
//
|
|
752
|
-
//
|
|
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
|
}
|
package/src/lineage/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|