@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,658 @@
1
+ //
2
+ // coverage.js — Sub-project E, increment 4 (E4).
3
+ //
4
+ // Implements DESIGN_GRAPH_BUILDER.md §9.4/§10 exactly: finishes the coverage
5
+ // ledger E1/E3 shipped only as a sketch (`graph-builder.js`'s own inline
6
+ // `graph.coverage = {...}` block — still present, still the DEFAULT when a
7
+ // caller uses `buildDataFlowGraph` directly; this module supersedes it only
8
+ // for a caller that opts in via `buildGraphWithCoverage`), and closes FR-203
9
+ // (a recognized sink whose destination expression could not be statically
10
+ // resolved — `fetch(url)` with a computed `url`; an SDK client built from
11
+ // config).
12
+ //
13
+ // §9.4 item 3 ("ship §5's enumerator union as a real module function") is
14
+ // ALREADY DONE — `graph-builder.js` exports `degradedTerminals(store)` as a
15
+ // real, tested function (E3). This module reads its result via `built.degraded`,
16
+ // it does not reimplement it.
17
+ //
18
+ // §9.4 item 5's hook-vs-post-processing-pass decision: an `opts` hook on
19
+ // `buildDataFlowGraph` (`resolveSiteDecision`, shipped in Task 1), not a
20
+ // post-processing pass over the built graph — see graph-builder.js's own
21
+ // header for why a post-processing pass was rejected (FR-203 changes a
22
+ // node's identity discriminator, so "adjust after minting" means re-minting).
23
+ //
24
+ // Reuse boundary: imports ONLY `reclassifySink` from `./sink-registry.js`,
25
+ // `DEFAULTS` from `./path-query.js`, and `buildDataFlowGraph` from
26
+ // `./graph-builder.js` — all three already-shipped `src/lineage/` modules.
27
+ // Never `dataflow/engine.js`, never `dataflow/summaries.js`.
28
+ //
29
+ // Disclosure: FR-203's own `external-api` headline example ("fetch(url)
30
+ // with a computed url") is currently unreachable on this project's real
31
+ // fixture through the privacy-catalog guard below — `vulnerable-js`'s only
32
+ // `external-api` sites are `privacy-js-axios-post`, and
33
+ // `reclassifyPrivacySink` has no `opts.destinationUnresolved` parameter for
34
+ // `resolveSiteDecision` to invoke (a disclosed, deliberate asymmetry — see
35
+ // `sink-registry.js`'s own header). Correct and deliberate; recorded here
36
+ // so the gap is visible rather than silently true of the only real fixture
37
+ // in this tree today. FR-203 DOES fire on `vulnerable-js` via a different
38
+ // category — `file`/`js-fs-readFile` (a general, CWE-keyed catalog entry) —
39
+ // so `unresolvedDestinations` is genuinely nonzero on that fixture; only
40
+ // the `external-api`/`fetch`-shaped case is unreachable there.
41
+ //
42
+ // Disclosure: the RECEIVER signal (a non-plain-ident callee object) fires
43
+ // on any fixed-but-nontrivial property chain, not just a genuinely dynamic
44
+ // expression — `this.db.query(q)` and `ctx.services.db.query(q)` both fire,
45
+ // even though both are perfectly static, readable paths; only a bare
46
+ // single-identifier receiver (`pool.query(q)`) reads as "resolved". This is
47
+ // the same shape MUST-FIX 1 closed on the arg0 side, one axis over: the
48
+ // plain-ident/member discriminator approximates static resolvability, it
49
+ // doesn't decide it. Defensible (this module resolves nothing, so
50
+ // "unresolved" is an honest label for anything it can't itself read back
51
+ // out as a flat name) but will inflate `unresolvedDestinations` on any
52
+ // repository using an ORM/repository pattern with `this.`/nested-service
53
+ // receivers. Not narrowed in this increment — narrowing it to genuinely
54
+ // computed receivers (a `call` or computed-member object) is the natural
55
+ // follow-up if this proves noisy in practice.
56
+
57
+ import { reclassifySink } from './sink-registry.js';
58
+ import { DEFAULTS as PATH_QUERY_DEFAULTS } from './path-query.js';
59
+ import { buildDataFlowGraph } from './graph-builder.js';
60
+ // Milestone 2, Sub-project A, increment 1: `resolveDestination` composes
61
+ // with `resolveSiteDecision` below the same way it's imported here — a real
62
+ // ES module cycle (resolve-destination.js imports THREE things back from
63
+ // this file). See that module's own header for why this is safe. Kept as
64
+ // its own import line, not folded into the block above, so the cycle is
65
+ // visible to a reader at a glance.
66
+ import { resolveDestination } from './resolve-destination.js';
67
+ // Milestone 2, Sub-project B, increment 2: `resolveTransitProtectionForSite`
68
+ // composes with `opts.resolveTransitProtection` the same way `resolveDestination`
69
+ // composes above — see `buildGraphWithCoverage`'s own wiring below. No ES
70
+ // module cycle here (unlike `resolve-destination.js`) — `transit-protection.js`
71
+ // never imports anything back from this file.
72
+ import { resolveTransitProtectionForSite } from './transit-protection.js';
73
+ // Deliverable #10 (DFG-020, graph-derived DPIA/RoPA migration): reused
74
+ // UNMODIFIED, per the plan's own Global Constraints — this module never
75
+ // re-implements governance-field lookup, only composes the default hook
76
+ // `graph-builder.js`'s `opts.resolveGovernanceRefs` expects.
77
+ import { governanceRecordFor, GOVERNANCE_FIELDS } from '../dataflow/privacy-governance.js';
78
+ // Milestone 4, FR-506 (Third-Party and Cross-Border Intelligence):
79
+ // `buildRecipientProfile` composes with `opts.buildRecipientProfile` the
80
+ // same way `resolveDestination`/`resolveTransitProtectionForSite` compose
81
+ // above — see `buildGraphWithCoverage`'s own wiring below. No ES module
82
+ // cycle here — `recipient-registry.js` never imports anything back from
83
+ // this file.
84
+ import { buildRecipientProfile } from './recipient-registry.js';
85
+ // Milestone 5, language coverage-tier disclosure: `coverageTierForLanguage`
86
+ // composes into `buildCoverageLedger`'s `languages` computation below —
87
+ // pure, static data, no cycle risk (mirrors how every other sibling import
88
+ // above composes into this file's own hooks/computations).
89
+ import { coverageTierForLanguage } from './language-coverage-tiers.js';
90
+ // M5 deliverable #7 (FR-505/AC-29, Runtime-Corroborated Digital Twin, "7b"):
91
+ // `correlateObservations` composes into `opts.correlateObservations` the
92
+ // same way every other sibling default hook in this file composes — see
93
+ // `buildGraphWithCoverage`'s own wiring below. No ES module cycle here —
94
+ // `observation-correlation.js` never imports anything back from this file.
95
+ import { correlateObservations } from './observation-correlation.js';
96
+
97
+ // =========================================================================
98
+ // FR-203 — the destination-unresolved heuristic.
99
+ //
100
+ // No catalog entry carries per-call-site destination information
101
+ // (DESIGN_REGISTRIES.md §7.5), so this heuristic works from the two shapes
102
+ // a real call site actually offers: the CALLEE (is the receiver a plain
103
+ // bound identifier, or a computed expression — "an SDK client built from
104
+ // config"?) and the first ARGUMENT (is it a literal, or a computed
105
+ // expression — "fetch(url) with a computed url"?). Either one firing is
106
+ // enough; the two are deliberately not required to agree, since a call
107
+ // site can be unresolvable via only one of the two shapes.
108
+ // =========================================================================
109
+
110
+ function isPlainIdent(e) {
111
+ return Boolean(e) && typeof e === 'object' && e.kind === 'ident' && typeof e.name === 'string';
112
+ }
113
+
114
+ /** Renders an IR expression node as a short, human-readable string for a
115
+ * FR-203 `reason` — never throws, never returns an empty string. */
116
+ export function renderExpr(e, depth = 0) {
117
+ if (depth > 4 || e == null) return '<computed expression>';
118
+ if (typeof e === 'string') return e;
119
+ if (typeof e !== 'object') return '<computed expression>';
120
+ if (e.kind === 'ident' && typeof e.name === 'string') return e.name;
121
+ if (e.kind === 'literal') return JSON.stringify(e.value);
122
+ if (e.kind === 'member' && typeof e.prop === 'string') return `${renderExpr(e.object, depth + 1)}.${e.prop}`;
123
+ return '<computed expression>';
124
+ }
125
+
126
+ /**
127
+ * §9.4 item 2's heuristic. `site` is one entry from `enumerateSinkSites`'s
128
+ * `sites[]` (post Task 1: carries `.calleeExpr` and `.args`). Returns
129
+ * `null` when nothing here looks unresolvable — never a guess in the
130
+ * unresolved direction. The result names WHICH signal fired (`via`):
131
+ * `'receiver'` (a computed callee receiver — "an SDK client built from
132
+ * config") or `'arg0'` (a non-literal first argument — "fetch(url) with a
133
+ * computed url"). `resolveSiteDecision` uses `via` to apply the narrower
134
+ * category gate the arg0 signal needs (see `FR203_ARG0_DESTINATION_CATEGORIES`
135
+ * below) — this function itself stays category-blind, since it has no
136
+ * access to the site's decision/category and is also exercised directly,
137
+ * without a category, by this module's own unit tests.
138
+ */
139
+ export function detectUnresolvedDestination(site) {
140
+ const callee = site.calleeExpr;
141
+ if (callee && typeof callee === 'object' && callee.kind === 'member' && typeof callee.prop === 'string' && !isPlainIdent(callee.object)) {
142
+ return { blockingExpression: `${renderExpr(callee.object)}.${callee.prop}`, via: 'receiver' };
143
+ }
144
+ const arg0 = (site.args ?? [])[0];
145
+ if (arg0 && typeof arg0 === 'object' && arg0.kind !== 'literal') {
146
+ return { blockingExpression: renderExpr(arg0), via: 'arg0' };
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // Categories whose node kind genuinely has a "destination" concept — a
152
+ // target system or location an argument/receiver could name. Excludes
153
+ // 'sink' (http-response/declared — the destination IS the call itself,
154
+ // always fixed) and 'log' (same reasoning). Mirrors sink-registry.js's own
155
+ // CATEGORY_NODE_KIND vocabulary; not re-derived, just filtered against.
156
+ // This is the RECEIVER signal's own eligibility set — the receiver is the
157
+ // destination handle in every one of these (an SDK client, a DB cursor, a
158
+ // queue channel), so the receiver check applies to all three.
159
+ //
160
+ // 'queue' is currently unreachable in practice: no `sink-registry.js`
161
+ // `CWE_MAP` row maps to `queue` (only `PRIVACY_CATEGORY_MAP`'s `queues` row
162
+ // does, and privacy-catalog sites are excluded above by the `vuln.cwe ===
163
+ // undefined` guard) — kept for forward-compatibility with a future
164
+ // CWE_MAP row, not a bug.
165
+ const FR203_ELIGIBLE_KINDS = Object.freeze(['external', 'store', 'queue']);
166
+
167
+ // The ARGUMENT signal's own, NARROWER eligibility set (MUST-FIX 1). Unlike
168
+ // the receiver, the first argument only actually NAMES the destination for
169
+ // these three categories (`fetch(url)`, `fs.writeFile(path, data)`,
170
+ // `s3.putObject(key, body)`) — everywhere else in `FR203_ELIGIBLE_KINDS`
171
+ // (overwhelmingly `database`/`client-storage`), the first argument is the
172
+ // PAYLOAD being sent TO an already-resolved destination named by the
173
+ // receiver (`cursor.execute(sql)`, `document.write(html)`), so treating a
174
+ // non-literal payload as "destination unresolved" is a false positive —
175
+ // measured live: 54 of 86 FR-203-eligible catalog entries (63%) are
176
+ // `database`(48)/`client-storage`(6), where the payload argument is
177
+ // non-literal by construction, so the un-gated arg0 signal fired
178
+ // unconditionally there and carried no information.
179
+ // `object-storage` is currently unreachable via `CWE_MAP` today (like
180
+ // `queue` above) — kept for forward-compatibility with a future row that
181
+ // maps to it, not a bug.
182
+ export const FR203_ARG0_DESTINATION_CATEGORIES = Object.freeze(['external-api', 'file', 'object-storage']);
183
+
184
+ /**
185
+ * The exact shape `buildDataFlowGraph`'s `opts.resolveSiteDecision` hook
186
+ * expects: `(site) -> decision | undefined`. Composes with §4.3's
187
+ * multi-candidate ambiguity resolution rather than silently discarding it
188
+ * — see the Global Constraints note in this plan and the comment below.
189
+ */
190
+ export function resolveSiteDecision(site) {
191
+ // Privacy-catalog entries are identified by carrying their own `category`
192
+ // field — the literal field `reclassifyPrivacySink` keys on, and one no
193
+ // general CATALOG sink entry ever has (sink-registry.js's own `D1/8b`
194
+ // pins this for the general side; independently re-confirmed live: 0 of
195
+ // 194 general sink entries carry `category`). `reclassifySink`'s `opts`
196
+ // parameter is specified only for the general (CWE-keyed) catalog
197
+ // (sink-registry.js's own disclosed asymmetry) — never applied here.
198
+ //
199
+ // CORRECTED (hotfix, 2026-08-31): the original guard checked
200
+ // `site.entry?.vuln?.cwe === undefined`, on the assumption that
201
+ // privacy-catalog entries carry no `vuln.cwe`. Measured, live: ALL 18 of
202
+ // 18 PRIVACY_SINK_CATALOG entries carry `vuln.cwe: 'CWE-359'` — the
203
+ // guard's premise was false for every single entry, so it excluded
204
+ // nothing. Since `'CWE-359'` has no `CWE_MAP` row, every privacy-catalog
205
+ // site that also triggered FR-203's heuristic silently fell through
206
+ // `reclassifySink`'s "unreachable from live data" fallback branch,
207
+ // corrupting a real store/external/queue-kind node into
208
+ // process/null/unsupported. Found and root-caused during Sub-project F's
209
+ // own corpus-authoring review; see
210
+ // docs/superpowers/plans/2026-08-31-lineage-coverage-privacy-catalog-fr203-hotfix.md.
211
+ if (typeof site.entry?.category === 'string') return undefined;
212
+ // Defensive: `resolveSiteDecision` is exported as a hook contract (passed
213
+ // straight into `buildDataFlowGraph`'s `opts.resolveSiteDecision`), so it
214
+ // should be as defensive against a malformed site as
215
+ // `detectUnresolvedDestination`/`renderExpr` already are — a site with no
216
+ // `.decision` at all must not throw here.
217
+ if (!site.decision) return undefined;
218
+ // A null-category (unsupported/process) decision has no category to
219
+ // retain — reclassifySink's own guard already refuses this combination;
220
+ // checking it here too avoids computing a heuristic result that would
221
+ // just be thrown away.
222
+ if (site.decision.category === null) return undefined;
223
+ if (!FR203_ELIGIBLE_KINDS.includes(site.decision.kind)) return undefined;
224
+
225
+ const unresolved = detectUnresolvedDestination(site);
226
+ if (!unresolved) return undefined;
227
+ // MUST-FIX 1: the arg0 signal only actually names a destination for the
228
+ // narrower FR203_ARG0_DESTINATION_CATEGORIES set — see that constant's
229
+ // own comment. The receiver signal has no such extra gate (it's eligible
230
+ // for everything FR203_ELIGIBLE_KINDS already allowed above).
231
+ if (unresolved.via === 'arg0' && !FR203_ARG0_DESTINATION_CATEGORIES.includes(site.decision.category)) return undefined;
232
+
233
+ const fr203 = reclassifySink(site.entry, {
234
+ destinationUnresolved: true,
235
+ blockingExpression: unresolved.blockingExpression,
236
+ });
237
+
238
+ // §4.3's plurality resolution already demoted `site.decision.coverageStatus`
239
+ // to 'partial' and appended an "AMBIGUOUS..." reason, entirely at the SITE
240
+ // level — `site.entry` is only the winning candidate's raw entry, so a
241
+ // bare `reclassifySink(site.entry, opts)` call knows nothing about that
242
+ // demotion and would silently produce `fr203.coverageStatus` from
243
+ // CWE_MAP fresh, discarding it. Carry the site-level adjustment forward
244
+ // when it happened; otherwise `fr203`'s own values already agree with
245
+ // `site.decision`'s (nothing to carry).
246
+ const ambiguityAdjusted = site.ambiguity?.resolvedBy === 'plurality';
247
+ return {
248
+ ...fr203,
249
+ coverageStatus: ambiguityAdjusted ? site.decision.coverageStatus : fr203.coverageStatus,
250
+ reason: ambiguityAdjusted ? `${fr203.reason} (site: ${site.decision.reason})` : fr203.reason,
251
+ };
252
+ }
253
+
254
+ // =========================================================================
255
+ // The coverage ledger (§10).
256
+ // =========================================================================
257
+
258
+ // Worst-wins precedence when a category's sites/seeds carry more than one
259
+ // coverageStatus — mirrors protection.js's aggregateVerdicts() and
260
+ // flow-grade.js's _PRECEDENCE risk-precedence-reduction convention, the
261
+ // established pattern in this package for "one summary value from several
262
+ // individually-graded inputs, worst wins".
263
+ const STATUS_PRECEDENCE = Object.freeze(['unsupported', 'candidate', 'partial', 'modeled']);
264
+ function worstStatus(a, b) {
265
+ const ia = STATUS_PRECEDENCE.indexOf(a);
266
+ const ib = STATUS_PRECEDENCE.indexOf(b);
267
+ if (ia === -1) return b;
268
+ if (ib === -1) return a;
269
+ return ia <= ib ? a : b;
270
+ }
271
+
272
+ function byCategorySorted(build) {
273
+ const out = {};
274
+ for (const key of [...build.keys()].sort()) out[key] = build.get(key);
275
+ return out;
276
+ }
277
+
278
+ /** §10's `sources.byCategory` — from `built.seeds` (already category- and
279
+ * coverageStatus-tagged by source-registry.js's `reclassifySource`, per
280
+ * `source-seeding.js`'s own seed shape). Null-category seeds are excluded
281
+ * — the source registry's own tests already prove every source category
282
+ * is non-null (source-registry.js has no `unsupported` tier, D2's own
283
+ * measured 84/14/82/0 split), so this is a defensive exclusion, not a
284
+ * documented real case. */
285
+ function sourcesByCategory(seeds) {
286
+ const m = new Map();
287
+ for (const s of seeds) {
288
+ if (s.category == null) continue;
289
+ if (!m.has(s.category)) m.set(s.category, { sites: 0, coverageStatus: s.coverageStatus });
290
+ const e = m.get(s.category);
291
+ e.sites += 1;
292
+ e.coverageStatus = worstStatus(e.coverageStatus, s.coverageStatus);
293
+ }
294
+ return byCategorySorted(m);
295
+ }
296
+
297
+ /** §10's `sinks.byCategory` — from `built.sites` (post any
298
+ * `opts.resolveSiteDecision` override, and post Task 1's `site.connected`
299
+ * stamp). Null-category (process/unsupported) sites are excluded — their
300
+ * existence is already guaranteed visible via AC-11's coarse half (every
301
+ * discovered sink becomes a node, `kind: 'process'`), so a per-category
302
+ * breakdown for a category that by definition doesn't exist would be
303
+ * misleading, not informative. */
304
+ function sinksByCategory(sites) {
305
+ const m = new Map();
306
+ for (const s of sites) {
307
+ if (s.decision.category == null) continue;
308
+ if (!m.has(s.decision.category)) m.set(s.decision.category, { sites: 0, connected: 0, coverageStatus: s.decision.coverageStatus });
309
+ const e = m.get(s.decision.category);
310
+ e.sites += 1;
311
+ if (s.connected) e.connected += 1;
312
+ e.coverageStatus = worstStatus(e.coverageStatus, s.decision.coverageStatus);
313
+ }
314
+ return byCategorySorted(m);
315
+ }
316
+
317
+ // Language dispatch — mirrors ir/index.js's own extension-based dispatch
318
+ // (never imported directly: that module has no exported "which language is
319
+ // this file" function, only its own internal parse dispatch, and importing
320
+ // ir/index.js here would cross a reuse boundary no other src/lineage/
321
+ // module crosses). A small, stable, independently-testable duplicate.
322
+ const LANGUAGE_EXT_PATTERNS = Object.freeze([
323
+ [/\.(?:js|jsx|ts|tsx|mjs|cjs|mts|cts)$/i, 'js'],
324
+ [/\.py$/i, 'python'],
325
+ [/\.java$/i, 'java'],
326
+ [/\.cs$/i, 'csharp'],
327
+ [/\.kt$/i, 'kotlin'],
328
+ [/\.go$/i, 'go'],
329
+ [/\.(?:php|phtml)$/i, 'php'],
330
+ [/\.rb$/i, 'ruby'],
331
+ [/\.(?:c|cc|cpp|cxx|h|hh|hpp|hxx)$/i, 'cpp'],
332
+ // Milestone 5, language coverage-tier disclosure: these 4 have ZERO
333
+ // lineage/taint wiring (tree-sitter-pattern-only, sast/tree-sitter-sinks.js
334
+ // only) — added here purely so a coverage-ledger `languages[]` entry can
335
+ // honestly attribute their files to a real language bucket, distinct from
336
+ // a genuinely unrecognized extension, rather than silently folding them
337
+ // into the shared 'unknown' fallback below.
338
+ [/\.rs$/i, 'rust'],
339
+ [/\.sol$/i, 'solidity'],
340
+ [/\.swift$/i, 'swift'],
341
+ [/\.dart$/i, 'dart'],
342
+ ]);
343
+ function languageForFile(file) {
344
+ for (const [re, lang] of LANGUAGE_EXT_PATTERNS) if (re.test(file)) return lang;
345
+ return 'unknown';
346
+ }
347
+
348
+ /**
349
+ * §10's finished coverage-ledger contract. `built` is `buildDataFlowGraph`'s
350
+ * own return value (`{graph, store, hops, seeds, unseedable, sites,
351
+ * nonStatementSites, degraded, stats, decisionsByNodeId}`) — this function
352
+ * reads it, never rebuilds any of it.
353
+ *
354
+ * @param {object} built
355
+ * @param {object} [opts]
356
+ * @param {Record<string, object>} [opts.perFile] the same `{file: irRecord}`
357
+ * map `runScan`'s `_sharedIR.perFile` holds — used ONLY to count
358
+ * successfully-analyzed files per language. Optional: a caller with no
359
+ * file list (e.g. a unit test building a callGraph by hand) gets
360
+ * `languages: []`, honestly empty, never fabricated.
361
+ * @param {Array<{file: string, language?: string, message?: string}>} [opts.parseFailures]
362
+ * per-file parse-failure records. A `callGraph`-only builder cannot see
363
+ * these itself (a parse failure never reaches `callGraph` at all —
364
+ * DESIGN_GRAPH_BUILDER.md §9.4 item 5b's own note); a caller with the
365
+ * real file list (E5/`runScan`) supplies them. Optional, defaults to `[]`.
366
+ * NOTE for that future caller: `ir/index.js`'s own parse-failure tracking
367
+ * (`irParseFailures().byLanguage`) keys by raw extension (`'py'`, `'ts'`,
368
+ * `'java'`), not this module's normalized `languageForFile` vocabulary
369
+ * (`'python'`, `'js'`) — feed `irParseFailures()`'s entries through
370
+ * `languageForFile(file)` (or supply an explicit `language`) rather than
371
+ * its raw extension key, or `languages[]` will report split/duplicated
372
+ * buckets for the same language.
373
+ * @param {object} [opts.budget] the same budget object passed to
374
+ * `buildDataFlowGraph`'s own `opts.budget` — used only to report which
375
+ * values were ACTUALLY in effect (merged over path-query.js's DEFAULTS,
376
+ * the same way `reconstructPaths` itself merges them).
377
+ */
378
+ export function buildCoverageLedger(built, opts = {}) {
379
+ const perFile = opts.perFile ?? {};
380
+ const parseFailures = (opts.parseFailures ?? []).map((f) => ({
381
+ file: f.file, language: f.language ?? languageForFile(f.file), message: f.message ?? null,
382
+ }));
383
+
384
+ const filesAnalyzedByLang = new Map();
385
+ for (const file of Object.keys(perFile)) {
386
+ const lang = languageForFile(file);
387
+ filesAnalyzedByLang.set(lang, (filesAnalyzedByLang.get(lang) ?? 0) + 1);
388
+ }
389
+ const filesFailedByLang = new Map();
390
+ for (const f of parseFailures) filesFailedByLang.set(f.language, (filesFailedByLang.get(f.language) ?? 0) + 1);
391
+ const allLangs = new Set([...filesAnalyzedByLang.keys(), ...filesFailedByLang.keys()]);
392
+ const languages = [...allLangs].sort().map((language) => {
393
+ const filesAnalyzed = filesAnalyzedByLang.get(language) ?? 0;
394
+ const base = { language, filesExpected: filesAnalyzed + (filesFailedByLang.get(language) ?? 0), filesAnalyzed };
395
+ // Milestone 5, language coverage-tier disclosure: coverageTierForLanguage
396
+ // never fabricates — a language with no curated entry (including the
397
+ // shared 'unknown' bucket for anything languageForFile can't recognize)
398
+ // gets tier: 'unknown' and no recall/measuredAt/source fields at all,
399
+ // never a guessed or zeroed-out number.
400
+ const tierEntry = coverageTierForLanguage(language);
401
+ if (!tierEntry) return { ...base, tier: 'unknown' };
402
+ const { tier, irTaintRecallPct, measuredAt, source } = tierEntry;
403
+ return irTaintRecallPct == null ? { ...base, tier } : { ...base, tier, irTaintRecallPct, measuredAt, source };
404
+ });
405
+
406
+ const unresolvedDestinations = built.sites.filter((s) => s.decision.kind === 'unresolved').length;
407
+ const storeStats = built.store.stats();
408
+
409
+ return {
410
+ languages, parseFailures,
411
+ destinationResolutionStatus: 'not-attempted', // FR-202 is Milestone 2 — unchanged from E3's sketch
412
+ pathBudgetTruncation: built.stats.truncatedQueries > 0,
413
+
414
+ sources: {
415
+ matched: built.seeds.length,
416
+ unseedable: built.unseedable.length,
417
+ dataElements: built.graph.dataElements.length,
418
+ byCategory: sourcesByCategory(built.seeds),
419
+ },
420
+ sinks: {
421
+ callStatementSites: built.sites.length,
422
+ connected: built.stats.connectedSinkSites,
423
+ disconnected: built.sites.length - built.stats.connectedSinkSites,
424
+ nonStatementSitesNotEnumerable: built.nonStatementSites.length,
425
+ // §10's own rule ("a node dropped and a node with no path must be
426
+ // distinguishable") applies to the reconciliation gap too: sites
427
+ // with a null category (kind:'process', AC-11's coarse half) are
428
+ // deliberately excluded from `byCategory` (see `sinksByCategory`'s
429
+ // own doc comment) but must not just vanish from the totals a reader
430
+ // sums — this count is the named residual, so
431
+ // `unsupportedSites + Σ byCategory[*].sites === callStatementSites`
432
+ // always holds.
433
+ unsupportedSites: built.sites.filter((s) => s.decision.category == null).length,
434
+ byCategory: sinksByCategory(built.sites),
435
+ },
436
+
437
+ degradedTerminals: built.degraded.length,
438
+ unresolvedDestinations,
439
+
440
+ paths: { enumerated: built.stats.pathsEnumerated, projected: built.stats.pathsProjected, truncatedQueries: built.stats.truncatedQueries },
441
+ budgets: { ...PATH_QUERY_DEFAULTS, ...(opts.budget ?? {}) },
442
+
443
+ provenance: { hops: built.hops.length, pnodes: storeStats.nodes, pedges: storeStats.edges },
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Convenience entry point: `buildDataFlowGraph` with FR-203 closed by
449
+ * default and the finished coverage ledger in place of E3's sketch.
450
+ * Returns the same shape `buildDataFlowGraph` returns — `built.graph.coverage`
451
+ * is the only field this function changes.
452
+ *
453
+ * @param {object} [opts.transitEvidenceByFile] Milestone 2, Sub-project B,
454
+ * increment 2: a pre-computed `Map<file, findings[]>` — the SAME `Map`
455
+ * `scanTransitEvidence` returns, computed exactly once by the caller
456
+ * (`index.js`'s `buildLineageGraph`), never raw `fileContents`. This
457
+ * function never calls `scanTransitEvidence`/`scanCryptoProtocol` itself
458
+ * — see `index.js`'s own header for why the computation must happen
459
+ * exactly once, in exactly one place.
460
+ * @param {object} [opts.privacySinkPolicy] Milestone 2, Sub-project G,
461
+ * increment 1: a pre-loaded `dataflow/privacy-sink-policy.js` policy
462
+ * object (`{allow: [...]}`), computed exactly once by the caller
463
+ * (`index.js`'s `buildLineageGraph`, mirroring `transitEvidenceByFile`'s
464
+ * own single-computation discipline). This function never reads the
465
+ * filesystem itself — it is spread straight through to
466
+ * `buildDataFlowGraph` via `...opts` below, unchanged, since this
467
+ * convenience wrapper has no default of its own to compose it with (only
468
+ * `resolveSiteDecision`/`resolveDestination`/`resolveTransitProtection`
469
+ * need one). `undefined` (never coerced to `{allow: []}` anywhere in this
470
+ * file) means "no policy evaluation was attempted" — see
471
+ * `graph-builder.js`'s own `policyLoaded` gate for why that distinction
472
+ * is load-bearing.
473
+ * @param {string} [opts.environment] optional deployment-environment
474
+ * override for policy evaluation's environment-scoped rules — also
475
+ * spread straight through, unchanged.
476
+ * @param {object} [opts.recipientConfig] Milestone 4, FR-506: a pre-loaded
477
+ * `recipient-registry.js`'s `loadRecipientConfig` return shape
478
+ * (`{recipients: {...}}`), computed exactly once by the caller
479
+ * (`index.js`'s `buildLineageGraph`, mirroring `transitEvidenceByFile`'s/
480
+ * `privacySinkPolicy`'s own single-computation discipline). This function
481
+ * never reads the filesystem itself — it only closes over this value for
482
+ * `opts.buildRecipientProfile`'s own default hook, below.
483
+ * @param {Array<object>} [opts.runtimeObservations] M5 deliverable #7
484
+ * (FR-505/AC-29): a PRE-LOADED `RuntimeObservation[]` array — never a
485
+ * path, the read happens once, upstream, in `index.js`'s
486
+ * `buildLineageGraph` (mirroring `opts.recipientConfig`'s own wording).
487
+ * `undefined` means "no observation store was consulted" (`index.js`'s
488
+ * own `existsSync` gate against `.agentic-security/runtime-observations/`
489
+ * never found the directory) and installs NO default
490
+ * `opts.correlateObservations` hook at all, so `graph.runtimeCorroboration`
491
+ * stays genuinely absent — the `not_evaluated` state. `[]` means "a store
492
+ * was consulted and is genuinely empty" and DOES install the default
493
+ * hook, producing a real correlation result whose every flow reads
494
+ * `not_observed_in_window` — the `opts.privacySinkPolicy`
495
+ * `undefined`-vs-`[]` precedent (`index.js:129-150`) applied to
496
+ * observations.
497
+ * @param {string} [opts.observationWindowStart] optional ISO-8601 lower
498
+ * bound for the correlation window, threaded verbatim into
499
+ * `correlateObservations`'s own `opts.windowStart`.
500
+ * @param {string} [opts.observationWindowEnd] optional ISO-8601 upper
501
+ * bound for the correlation window, threaded verbatim into
502
+ * `correlateObservations`'s own `opts.windowEnd`.
503
+ * @param {Array<object>} [opts.crossRepoLinkRecords] M5 deliverable #8
504
+ * (FR-304 "declared" half): a PRE-LOADED `CrossRepoLink[]` array —
505
+ * never a path, the read happens once, upstream, in `index.js`'s
506
+ * `buildLineageGraph` (mirroring `opts.recipientConfig`'s own
507
+ * wording). `undefined` means "no cross-repo-links.json was
508
+ * consulted" (`index.js`'s own `existsSync` gate never found the
509
+ * file) and installs NO default `opts.crossRepoLinks` hook at all —
510
+ * `graph.crossRepoLinks` still reads `[]` either way (the same
511
+ * visible OUTCOME as "consulted and genuinely empty"), unlike
512
+ * `opts.runtimeObservations`'s own `undefined`-vs-`[]` distinction,
513
+ * because a CrossRepoLink array carries no `not_evaluated` state to
514
+ * preserve the way runtime corroboration does (see `graph-builder.js`'s
515
+ * own comment on this hook for the full reasoning). The default hook,
516
+ * when installed, DROPS any record whose `local.nodeId` is not present
517
+ * in the graph's own current `nodes[]` (a stale declaration from
518
+ * before a rescan), reporting each dropped id via `console.error`
519
+ * rather than silently keeping it.
520
+ */
521
+ export function buildGraphWithCoverage(callGraph, opts = {}) {
522
+ // NITPICK 4: compose with a caller-supplied `opts.resolveSiteDecision`
523
+ // rather than silently clobbering it — a caller's own hook always wins,
524
+ // matching `buildDataFlowGraph`'s own "the hook, when present, replaces
525
+ // `site.decision`" contract rather than this convenience wrapper quietly
526
+ // overriding that caller's choice.
527
+ // Milestone 2, Sub-project A, increment 1: identical composition pattern
528
+ // for `opts.resolveDestination` — a caller-supplied hook always wins.
529
+ // Milestone 2, Sub-project B, increment 2: identical composition pattern
530
+ // for `opts.resolveTransitProtection` — a caller-supplied hook always
531
+ // wins over this module's own default, which closes over
532
+ // `opts.transitEvidenceByFile` (honestly empty, via the `?? new Map()`
533
+ // fallback, when the caller supplies neither — never re-derived from
534
+ // `opts.fileContents`, which this function never reads).
535
+ const built = buildDataFlowGraph(callGraph, {
536
+ ...opts,
537
+ resolveSiteDecision: opts.resolveSiteDecision ?? resolveSiteDecision,
538
+ resolveDestination: opts.resolveDestination ?? resolveDestination,
539
+ resolveTransitProtection: opts.resolveTransitProtection
540
+ ?? ((site) => resolveTransitProtectionForSite(site, opts.transitEvidenceByFile ?? new Map())),
541
+ // Milestone 4, FR-506: identical composition pattern — a caller-supplied
542
+ // hook always wins over this module's own default, which closes over
543
+ // `opts.recipientConfig` (honestly `undefined`, via `buildRecipientProfile`'s
544
+ // own `opts.recipientConfig` fallback, when the caller supplies none —
545
+ // never re-derived from the filesystem here).
546
+ buildRecipientProfile: opts.buildRecipientProfile
547
+ ?? ((site, graph) => buildRecipientProfile(site, graph, { recipientConfig: opts.recipientConfig })),
548
+ // Deliverable #10 (DFG-020): opts.resolveGovernanceRefs, composed the
549
+ // same way every sibling hook is — a caller-supplied hook always wins.
550
+ // The default closes over opts.privacyGovernanceConfig, a PRE-LOADED
551
+ // config object (mirroring opts.transitEvidenceByFile's own
552
+ // single-computation-per-buildLineageGraph-call discipline — never
553
+ // read the filesystem itself here).
554
+ resolveGovernanceRefs: opts.resolveGovernanceRefs
555
+ ?? ((dataClasses) => {
556
+ // Task-1 review finding (non-blocking, fixed): a flow whose
557
+ // dataElement classified into NO named class (dataClasses: [])
558
+ // used to fall straight through this loop and return {} — no
559
+ // per-field MANUAL_REQUIRED at all. Privacy View's own rendering
560
+ // (`if (key in row.governanceRefs)`) then shows ZERO governance
561
+ // badges for such a flow, indistinguishable from a genuine
562
+ // nothing-to-disclose case — in tension with this codebase's own "missing
563
+ // evidence renders as unknown, never as not-applicable"
564
+ // convention (e.g. OSCAL's "an unassessed control gets no
565
+ // finding" is the SAME direction, never the reverse: absence of
566
+ // assessment must never read as absence of obligation). An
567
+ // unclassified data element still deserves the same governance
568
+ // scrutiny an operator-classified one does — its purpose/lawful
569
+ // basis/etc. are still real open questions, not inapplicable
570
+ // ones — so the honest answer is the full field set, all
571
+ // MANUAL_REQUIRED (or whatever the operator's scan-wide
572
+ // `config.default` supplies), never an empty object.
573
+ // `governanceRecordFor('(unclassified)', ...)` achieves this for
574
+ // free: an unmatched class key already falls through to
575
+ // `config.default`/MANUAL_REQUIRED in governanceRecordFor's own
576
+ // logic — no special-casing needed here.
577
+ const classesToResolve = dataClasses.length ? dataClasses : ['(unclassified)'];
578
+ const record = {};
579
+ for (const cls of classesToResolve) {
580
+ const clsRecord = governanceRecordFor(cls, opts.privacyGovernanceConfig ?? null);
581
+ for (const field of GOVERNANCE_FIELDS) {
582
+ // Worst-case-wins across multiple data classes on one flow,
583
+ // mirroring this package's own established
584
+ // aggregateVerdicts()-style precedent (protection.js) rather
585
+ // than silently picking whichever data class happened to be
586
+ // iterated last: an operator-provided value only wins over an
587
+ // already-recorded operator-provided value for the SAME field
588
+ // if this is the first class seen; MANUAL_REQUIRED never
589
+ // overwrites an already-resolved operator-provided value.
590
+ if (!record[field] || record[field].source === 'manual_required') {
591
+ record[field] = clsRecord[field];
592
+ }
593
+ }
594
+ }
595
+ return record;
596
+ }),
597
+ // M5 deliverable #7 (FR-505/AC-29): identical composition pattern — a
598
+ // caller-supplied hook always wins. The default is installed ONLY when
599
+ // `opts.runtimeObservations` is genuinely defined; when it is `undefined`
600
+ // (no store on disk, per index.js's own existsSync gate) NO hook is
601
+ // installed at all, so `graph.runtimeCorroboration` stays absent and reads
602
+ // as `not_evaluated`. This is exactly the `undefined`-vs-`[]` distinction
603
+ // `opts.privacySinkPolicy` already draws for a missing privacy-policy.json,
604
+ // and collapsing the two would make "we never looked" indistinguishable
605
+ // from a genuine "we looked and saw nothing" — PRD line 2098's own prohibition.
606
+ correlateObservations: opts.correlateObservations
607
+ ?? (opts.runtimeObservations !== undefined
608
+ ? ((graph) => correlateObservations(graph, opts.runtimeObservations, {
609
+ // I3 (final review): this default hook used to resolve
610
+ // opts.environment ?? null with no environment-variable
611
+ // fallback, unlike the sibling flow.policyVerdict path
612
+ // (graph-builder.js's own opts.environment || process.env
613
+ // .AGENTIC_SECURITY_ENVIRONMENT || null) — so a scan run under
614
+ // AGENTIC_SECURITY_ENVIRONMENT=production correlated runtime
615
+ // observations with NO environment filter at all, letting a
616
+ // stale/wrong-environment observation corroborate a flow in the
617
+ // signed production graph. Matches graph-builder.js's own
618
+ // established convention exactly.
619
+ environment: opts.environment || process.env.AGENTIC_SECURITY_ENVIRONMENT || null,
620
+ windowStart: opts.observationWindowStart ?? null,
621
+ windowEnd: opts.observationWindowEnd ?? null,
622
+ }))
623
+ : undefined),
624
+ // M5 deliverable #8 (FR-304 "declared" half): identical composition
625
+ // pattern to `opts.correlateObservations` immediately above — a
626
+ // caller-supplied hook always wins. The default is installed ONLY when
627
+ // `opts.crossRepoLinkRecords` is genuinely defined (`index.js`'s own
628
+ // `existsSync` gate against `.agentic-security/cross-repo-links.json`
629
+ // found the file); when it is `undefined`, NO hook is installed and
630
+ // `graph.crossRepoLinks` stays `[]` via `graph-builder.js`'s own
631
+ // unconditional default — there is no `not_evaluated` state for this
632
+ // array to preserve, unlike `correlateObservations`'s own
633
+ // `undefined`-vs-`[]` distinction. When installed, the default hook
634
+ // drops any record whose `local.nodeId` no longer resolves against the
635
+ // CURRENT graph's own real node set (a stale declaration from before a
636
+ // rescan renamed/removed the node) rather than silently keeping it —
637
+ // matching `applyScenario`'s own "skippedOperations, never thrown"
638
+ // honesty precedent — and reports every drop via `console.error`.
639
+ crossRepoLinks: opts.crossRepoLinks
640
+ ?? (opts.crossRepoLinkRecords !== undefined
641
+ ? ((graph) => {
642
+ const nodeIds = new Set((graph.nodes ?? []).map((n) => n.id));
643
+ const kept = [];
644
+ const dropped = [];
645
+ for (const record of opts.crossRepoLinkRecords) {
646
+ if (record && record.local && nodeIds.has(record.local.nodeId)) kept.push(record);
647
+ else dropped.push(record);
648
+ }
649
+ if (dropped.length > 0) {
650
+ console.error(`agentic-security: dropped ${dropped.length} stale cross-repo link${dropped.length === 1 ? '' : 's'} (local.nodeId not found in the current graph): ${dropped.map((r) => (r && r.id) || '(malformed)').join(', ')}`);
651
+ }
652
+ return kept;
653
+ })
654
+ : undefined),
655
+ });
656
+ built.graph.coverage = buildCoverageLedger(built, opts);
657
+ return built;
658
+ }