@clear-capabilities/agentic-security-scanner 0.145.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 (162) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/bin/agentic-security.js +3508 -69
  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} +2 -2
  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/{271.index.js → 2271.index.js} +2 -2
  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} +5 -5
  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} +5 -5
  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} +2 -2
  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} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  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} +3 -3
  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} +560 -189
  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 +12 -5
  72. package/src/dataflow/CLAUDE.md +1 -1
  73. package/src/dataflow/catalog.js +42 -0
  74. package/src/dataflow/orm-write-catalog.js +175 -0
  75. package/src/engine.js +92 -1
  76. package/src/ir/CLAUDE.md +1 -0
  77. package/src/ir/chrome-probe.mjs +150 -0
  78. package/src/ir/parser-js.js +94 -7
  79. package/src/lineage/CLAUDE.md +1203 -0
  80. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  81. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  82. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  83. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  84. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  85. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  86. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  87. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  88. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  89. package/src/lineage/classification.js +56 -0
  90. package/src/lineage/coverage.js +658 -0
  91. package/src/lineage/cross-repo-link.js +107 -0
  92. package/src/lineage/dataflow-graph.schema.json +184 -0
  93. package/src/lineage/decision-story.js +206 -0
  94. package/src/lineage/drift-policy.js +279 -0
  95. package/src/lineage/driver.js +135 -0
  96. package/src/lineage/engine.js +992 -0
  97. package/src/lineage/export-briefing.js +628 -0
  98. package/src/lineage/export-csv.js +62 -0
  99. package/src/lineage/export-json.js +238 -0
  100. package/src/lineage/export-privacy.js +258 -0
  101. package/src/lineage/federation-loader.js +111 -0
  102. package/src/lineage/field-identity.js +78 -0
  103. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  104. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  105. package/src/lineage/flow-grade.js +221 -0
  106. package/src/lineage/governance-edit.js +169 -0
  107. package/src/lineage/graph-builder.js +1114 -0
  108. package/src/lineage/graph-diff.js +431 -0
  109. package/src/lineage/graph-snapshot.js +180 -0
  110. package/src/lineage/handling-analyzer.js +168 -0
  111. package/src/lineage/ids.js +349 -0
  112. package/src/lineage/impact-assessment.js +76 -0
  113. package/src/lineage/impact-engine.js +268 -0
  114. package/src/lineage/index.js +281 -0
  115. package/src/lineage/language-coverage-tiers.js +58 -0
  116. package/src/lineage/obligation-mapping.js +126 -0
  117. package/src/lineage/obligation-predicates.js +235 -0
  118. package/src/lineage/observation-adapters.js +282 -0
  119. package/src/lineage/observation-correlation.js +622 -0
  120. package/src/lineage/observation-store.js +497 -0
  121. package/src/lineage/path-query.js +410 -0
  122. package/src/lineage/path-store.js +400 -0
  123. package/src/lineage/protection.js +53 -0
  124. package/src/lineage/recipient-profile.js +192 -0
  125. package/src/lineage/recipient-registry.js +394 -0
  126. package/src/lineage/redact-graph.js +224 -0
  127. package/src/lineage/remediation.js +417 -0
  128. package/src/lineage/resolve-destination.js +91 -0
  129. package/src/lineage/runtime-observation.js +464 -0
  130. package/src/lineage/scenario-diff.js +84 -0
  131. package/src/lineage/scenario-engine.js +251 -0
  132. package/src/lineage/scenario.js +101 -0
  133. package/src/lineage/schema.js +167 -0
  134. package/src/lineage/sink-registry.js +427 -0
  135. package/src/lineage/source-registry.js +357 -0
  136. package/src/lineage/source-seeding.js +212 -0
  137. package/src/lineage/summaries.js +590 -0
  138. package/src/lineage/transform-catalog.js +397 -0
  139. package/src/lineage/transit-protection.js +150 -0
  140. package/src/lineage/validate.js +285 -0
  141. package/src/mcp/CLAUDE.md +7 -1
  142. package/src/mcp/dataflow-tools.js +160 -0
  143. package/src/mcp/server.js +1 -1
  144. package/src/mcp/tools.js +2 -1
  145. package/src/pipeline/scan-health.js +19 -1
  146. package/src/posture/CLAUDE.md +19 -0
  147. package/src/posture/artifact-registry.js +52 -0
  148. package/src/posture/auditor-walkthrough.js +76 -0
  149. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  150. package/src/posture/obligation-evidence-pack.js +202 -0
  151. package/src/posture/remediation-ledger.js +337 -0
  152. package/src/server/CLAUDE.md +47 -0
  153. package/src/server/graph-loader.js +141 -0
  154. package/src/server/http-server.js +325 -0
  155. package/src/server/routes.js +129 -0
  156. package/src/server/security.js +111 -0
  157. package/src/server/static-assets.js +139 -0
  158. package/dist/11.index.js +0 -353
  159. package/dist/259.index.js +0 -975
  160. package/dist/317.index.js +0 -300
  161. package/dist/609.index.js +0 -741
  162. package/dist/838.index.js +0 -152
@@ -0,0 +1,221 @@
1
+ //
2
+ // flow-grade.js — FR-306 edge grading (Sub-project C, increment 6).
3
+ //
4
+ // Binding spec: DESIGN_PATH_PROVENANCE.md §16 (§16.1-§16.10; design + PoC
5
+ // landed as Task 1, this is Task 2, the mechanical implementation). A
6
+ // PURE grading function library: it consumes ONLY the fields a
7
+ // `path-query.js` `Hop`/`Path` or a raw `path-store.js` edge already
8
+ // carries. **Zero imports** — one step stricter than `path-query.js`'s own
9
+ // `['./ids.js']` boundary (§16.1). Never `engine.js`/`summaries.js`/
10
+ // `driver.js`, and never `path-store.js`/`path-query.js` either: grading
11
+ // needs neither a path nor the store, since a hop is a denormalized copy
12
+ // of its edge (proven by `C6/11`, over every edge of the 2-function
13
+ // resolved-call fixture — grading a raw `PathStore` edge and grading the
14
+ // `Hop` denormalized from it are byte-identical).
15
+ //
16
+
17
+ /**
18
+ * §16.2. The flow-evidence vocabulary, in CONFIDENCE order, most
19
+ * confident first. Deliberately NOT `protection.js`'s `EVIDENCE_GRADES`
20
+ * — see §16.2's rejection note (that enum grades the SOURCE of a
21
+ * protection verdict; this one grades how explicit a recorded data
22
+ * movement is, and every value here comes from the same evidence source).
23
+ *
24
+ * `implicit` is RESERVED: nothing in the engine emits a control-dependence
25
+ * reason today (§10.2 — "the engine models no implicit flow today; do not
26
+ * invent one"). It is present because FR-306 names it first and because
27
+ * §14.2's `origin` node kind set the precedent for keeping, and hand-
28
+ * testing, the exact shape a later increment will produce.
29
+ *
30
+ * `unassessed` is the empty-input answer only, mirroring
31
+ * `protection.js`'s `aggregateVerdicts` returning `'not_assessed'` for an
32
+ * empty array. Nothing ever grades a real hop `unassessed`.
33
+ */
34
+ export const FLOW_EVIDENCE_GRADES = Object.freeze([
35
+ 'explicit', 'widened', 'implicit', 'severed', 'ambiguous', 'unassessed',
36
+ ]);
37
+
38
+ /**
39
+ * §16.4. Risk precedence for aggregation — lower index wins, exactly like
40
+ * `protection.js`'s own private `_PRECEDENCE`. This is the reverse of the
41
+ * confidence order above for the five real grades, with `unassessed` kept
42
+ * last so it survives an aggregation only when there is nothing else in it.
43
+ * Kept private, exactly as `protection.js` keeps its own — `C6/0` is the
44
+ * parity check that stops it drifting from `FLOW_EVIDENCE_GRADES`.
45
+ */
46
+ const _PRECEDENCE = Object.freeze([
47
+ 'ambiguous', 'severed', 'implicit', 'widened', 'explicit', 'unassessed',
48
+ ]);
49
+
50
+ /**
51
+ * §16.3. Reason strings that mean "this hop is control-dependent, not a
52
+ * data assignment". Empty of anything the engine emits today, by design.
53
+ */
54
+ export const IMPLICIT_FLOW_REASONS = Object.freeze(['control-dependence']);
55
+
56
+ /** §16.5. Loss reasons that mean "an engine budget degraded this away". */
57
+ export const DEGRADED_LOSS_REASONS = Object.freeze(['context-cap-degraded']);
58
+
59
+ export function flowGradeRank(grade) {
60
+ const i = FLOW_EVIDENCE_GRADES.indexOf(grade);
61
+ if (i === -1) throw new Error(`flowGradeRank: unrecognized flow evidence grade "${grade}"`);
62
+ return i;
63
+ }
64
+
65
+ /**
66
+ * §16.4. Reduce hop grades to one path grade: the WORST wins. Never
67
+ * guesses — an empty array is `'unassessed'`, and an unrecognized grade
68
+ * throws rather than silently sorting last, verbatim `aggregateVerdicts`'
69
+ * own contract ("a typo here must not quietly rank as safest").
70
+ */
71
+ export function aggregateFlowGrades(grades) {
72
+ if (!Array.isArray(grades) || grades.length === 0) return 'unassessed';
73
+ let worst = null;
74
+ let worstRank = Infinity;
75
+ for (const g of grades) {
76
+ const rank = _PRECEDENCE.indexOf(g);
77
+ if (rank === -1) throw new Error(`aggregateFlowGrades: unrecognized flow evidence grade "${g}"`);
78
+ if (rank < worstRank) { worstRank = rank; worst = g; }
79
+ }
80
+ return worst;
81
+ }
82
+
83
+ function _sortedUnion(...arrays) {
84
+ const s = new Set();
85
+ for (const a of arrays) for (const v of a ?? []) if (v != null) s.add(v);
86
+ return [...s].sort();
87
+ }
88
+
89
+ /**
90
+ * §16.3. Grade ONE hop. Accepts either a `path-query.js` `Hop` or a raw
91
+ * `path-store.js` EDGE — they carry the same grading fields, a hop being a
92
+ * denormalized copy of its edge (proven by C6/11).
93
+ *
94
+ * **The annotation rule (§16.5), and it is load-bearing:** `widenReasons`
95
+ * and `lossReasons` are read as the UNION of the hop's own top-level
96
+ * arrays AND every `annotations[].widenReason` / `.lossReason`. §2.2
97
+ * classifies a null-`fromPath`, null-`peerScope` in-half as an annotation
98
+ * on the edges its siblings form, so a genuine widening recorded at an
99
+ * expression-internal construct lands ONLY in `annotations[]` — measured
100
+ * on three real fixtures (C6/5). A grader reading only the top-level
101
+ * arrays grades those flows `explicit`, which is FR-306's own literal
102
+ * prohibition.
103
+ *
104
+ * `crossScope` is carried as a FACTOR and never affects `grade` (§16.6).
105
+ */
106
+ export function gradeHop(hop) {
107
+ const annotations = Array.isArray(hop.annotations) ? hop.annotations : [];
108
+ const topWiden = Array.isArray(hop.widenReasons) ? hop.widenReasons : [];
109
+ const topLoss = Array.isArray(hop.lossReasons) ? hop.lossReasons : [];
110
+ const widenAll = _sortedUnion(topWiden, annotations.map((a) => a.widenReason));
111
+ const lossAll = _sortedUnion(topLoss, annotations.map((a) => a.lossReason));
112
+
113
+ // A control-dependence reason is not a widening — it selects the
114
+ // `implicit` tier and is removed from `widenReasons` so it never
115
+ // double-counts.
116
+ //
117
+ // §16.3: the subtraction is applied to the WIDEN side ONLY, deliberately
118
+ // (fix round 1, nitpick 8). Applying it to `lossAll` too would let a
119
+ // future `lossReason: 'control-dependence'` be silently UPGRADED from
120
+ // `severed` to the more-confident `implicit` tier — a grade moving in
121
+ // the optimistic direction because a new reason string was added
122
+ // elsewhere. Unreachable today (no loss reason is in the set), and kept
123
+ // unreachable by construction rather than by luck.
124
+ const isImplicit = (r) => IMPLICIT_FLOW_REASONS.includes(r);
125
+ const implicitReasons = widenAll.filter(isImplicit).sort();
126
+ const widenReasons = widenAll.filter((r) => !isImplicit(r));
127
+ const lossReasons = [...lossAll];
128
+
129
+ // Exactly which reasons would have been invisible to a top-level-only
130
+ // reader. Named, not merely folded — §18.4's transparency requirement
131
+ // applied to the grade's own inputs.
132
+ const annotationOnly = [
133
+ ...widenAll.filter((r) => !topWiden.includes(r)).map((r) => `widen:${r}`),
134
+ ...lossAll.filter((r) => !topLoss.includes(r)).map((r) => `loss:${r}`),
135
+ ].sort();
136
+
137
+ const ambiguousCorrelation = hop.ambiguousCorrelation === true;
138
+ const truncated = hop.truncated === true;
139
+ const crossScope = hop.crossScope === true;
140
+
141
+ const grade = ambiguousCorrelation ? 'ambiguous'
142
+ : lossReasons.length > 0 ? 'severed'
143
+ : implicitReasons.length > 0 ? 'implicit'
144
+ : widenReasons.length > 0 ? 'widened'
145
+ : 'explicit';
146
+
147
+ const factors = [
148
+ ...widenReasons.map((r) => `widen:${r}`),
149
+ ...lossReasons.map((r) => `loss:${r}`),
150
+ ...implicitReasons.map((r) => `implicit:${r}`),
151
+ ...(ambiguousCorrelation ? ['ambiguous-correlation'] : []),
152
+ ...(truncated ? ['analysis-truncated'] : []),
153
+ ...(crossScope ? ['cross-scope'] : []),
154
+ ].sort();
155
+
156
+ const degraded = lossAll.some((r) => DEGRADED_LOSS_REASONS.includes(r));
157
+ return {
158
+ grade,
159
+ rank: flowGradeRank(grade),
160
+ factors,
161
+ widenReasons,
162
+ lossReasons,
163
+ implicitReasons,
164
+ annotationOnly,
165
+ ambiguousCorrelation,
166
+ degraded,
167
+ truncated,
168
+ crossScope,
169
+ incomplete: grade === 'severed' || degraded || truncated,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * §16.4. Grade one reconstructed `Path`. The path's grade is the WORST of
175
+ * its hops' — `protection.js`'s `aggregateVerdicts` risk-precedence
176
+ * reduction, applied to the one axis FR-306 governs.
177
+ *
178
+ * Counts are recomputed from `gradeHop`, deliberately NOT read off the
179
+ * Path's own `widenedHopCount`/`lossHopCount`. `path-query.js`'s own
180
+ * `materialize()` now computes those fields annotation-aware too (§16.7
181
+ * Finding 1 / §16.8 item 7), so as of this module the two sources agree —
182
+ * but `gradePath` still computes its own union rather than re-coupling
183
+ * itself to `Path`'s fields: grading is what OWNS this union by design
184
+ * (§16.5), and a caller that can grade a raw `PathStore` edge with no
185
+ * `Path` at all (`C6/11`) must not depend on a `Path`-shaped count that
186
+ * doesn't exist for that input.
187
+ */
188
+ export function gradePath(path) {
189
+ const hops = Array.isArray(path.hops) ? path.hops : [];
190
+ const hopGrades = hops.map((h) => gradeHop(h));
191
+ const grade = aggregateFlowGrades(hopGrades.map((g) => g.grade));
192
+ const complete = path.complete === true;
193
+ const analysisTruncated = path.analysisTruncated === true;
194
+ const factors = _sortedUnion(
195
+ hopGrades.flatMap((g) => g.factors),
196
+ complete ? [] : ['partial-path'],
197
+ analysisTruncated ? ['analysis-truncated'] : [],
198
+ );
199
+ return {
200
+ grade,
201
+ rank: flowGradeRank(grade),
202
+ // §16.4 (fix round 1, finding 3): the FULL `HopGrade` objects, in path
203
+ // order — not a parallel array of bare grade strings. A bare-string
204
+ // array loses per-hop CAUSE, forcing every caller that wants to render
205
+ // FR-306's "visually distinct" half to re-invoke `gradeHop` per hop and
206
+ // re-derive what this function already computed.
207
+ hops: hopGrades,
208
+ worstHopIndex: hopGrades.findIndex((g) => g.grade === grade),
209
+ factors,
210
+ widenedHopCount: hopGrades.filter((g) => g.widenReasons.length > 0).length,
211
+ lossHopCount: hopGrades.filter((g) => g.lossReasons.length > 0).length,
212
+ implicitHopCount: hopGrades.filter((g) => g.implicitReasons.length > 0).length,
213
+ ambiguousHopCount: hopGrades.filter((g) => g.ambiguousCorrelation).length,
214
+ degradedHopCount: hopGrades.filter((g) => g.degraded).length,
215
+ truncatedHopCount: hopGrades.filter((g) => g.truncated).length,
216
+ degraded: hopGrades.some((g) => g.degraded),
217
+ truncated: analysisTruncated || hopGrades.some((g) => g.truncated),
218
+ complete,
219
+ incomplete: !complete || analysisTruncated || hopGrades.some((g) => g.incomplete),
220
+ };
221
+ }
@@ -0,0 +1,169 @@
1
+ // governance-edit.js — M5 deliverable #5 (PRD line 1324's 5-part write
2
+ // contract: preview, validation, backup/version guard, confirmation,
3
+ // audit event). A CLI-only workflow for proposing a validated,
4
+ // reviewable edit to recipient-profiles.json — the one governance
5
+ // config file this codebase already has real, tested per-entry
6
+ // validation for (isValidRecipientConfigEntry, recipient-registry.js).
7
+ //
8
+ // Deliberately narrower than the PRD's own richer "interactive review/
9
+ // approve UI" vision — the HTTP-server-side interactive write surface
10
+ // (new routes, CSRF protection, a write-authorization mechanism beyond
11
+ // the existing read-only session token) is real, separately-scoped
12
+ // future work, not attempted here. See this sub-project's own scoping
13
+ // doc for the full reasoning: no PRD acceptance criterion gates this
14
+ // deliverable at all, and every M4/M5 deliverable this session has
15
+ // shipped has been CLI-first with zero UI/HTTP-write work.
16
+ //
17
+ // This module is pure — no file I/O, no fs access. The CLI layer
18
+ // (bin/agentic-security.js's cmdGovernancePropose) owns reading the
19
+ // current file, writing the backup, writing the new content, and
20
+ // calling auditCall.
21
+
22
+ import { isValidRecipientConfigEntry } from './recipient-registry.js';
23
+
24
+ // Validates the top-level `{recipients: {...}}` container shape — used
25
+ // for BOTH the current on-disk config and the --patch file. A patch is
26
+ // user input the command's whole job is to validate, so it gets no
27
+ // tolerant degradation (fixes I2/I3: previously a patch missing
28
+ // `recipients` entirely, or with `recipients` as an array, degraded
29
+ // silently to an empty no-op write). The CURRENT config gets the same
30
+ // check for a different reason: if its top-level shape is unrecognized
31
+ // (e.g. a typo'd `Recipients` key), the tool must refuse rather than
32
+ // silently treating "no recognizable data" as "start from empty" and
33
+ // overwriting whatever WAS there (this was B1's second live repro —
34
+ // the whole file being replaced under a misspelled key).
35
+ function _validateContainerShape(recipients, label) {
36
+ if (recipients === undefined) {
37
+ return [{ key: '(top-level)', message: `${label} is missing a "recipients" object` }];
38
+ }
39
+ if (recipients === null || typeof recipients !== 'object' || Array.isArray(recipients)) {
40
+ return [{ key: '(top-level)', message: `${label}'s "recipients" must be a plain object, not ${Array.isArray(recipients) ? 'an array' : recipients === null ? 'null' : typeof recipients}` }];
41
+ }
42
+ const errors = [];
43
+ for (const key of Object.keys(recipients)) {
44
+ if (key.length === 0) errors.push({ key, message: `${label} contains an empty-string recipient key, which is never valid` });
45
+ if (key === '__proto__') errors.push({ key, message: `${label} contains a "__proto__" recipient key, which is never valid` });
46
+ }
47
+ return errors;
48
+ }
49
+
50
+ function _validateEntries(patchRecipients) {
51
+ const errors = [];
52
+ for (const [key, entry] of Object.entries(patchRecipients)) {
53
+ if (entry === null) continue; // explicit deletion marker, not a config entry
54
+ if (!isValidRecipientConfigEntry(entry)) {
55
+ errors.push({ key, message: `recipient "${key}" is not a valid recipient-profile-shaped config entry` });
56
+ }
57
+ }
58
+ return errors;
59
+ }
60
+
61
+ // RFC-7396-style merge patch at the recipient-key level: a non-null
62
+ // patch value REPLACES that key's entire entry; a null value DELETES
63
+ // it; a key the patch never mentions is left untouched. This is the
64
+ // fix for a real data-loss bug (found by this task's own review,
65
+ // live-reproduced): the prior design treated `patch.recipients` as
66
+ // the ENTIRE new recipients object, so omitting a key silently
67
+ // deleted it — a reasonable operator adding one vendor had no reason
68
+ // to expect every other vendor's compliance facts to vanish.
69
+ function _mergeRecipients(currentRecipients, patchRecipients) {
70
+ const merged = { ...currentRecipients };
71
+ for (const [key, value] of Object.entries(patchRecipients)) {
72
+ if (value === null) delete merged[key];
73
+ else merged[key] = value;
74
+ }
75
+ return merged;
76
+ }
77
+
78
+ // Canonical (key-order-independent) JSON serialization for change
79
+ // detection — mirrors ids.js's own _canon precedent. A hand-authored
80
+ // patch file has no reason to preserve the stored config's own key
81
+ // order, so a raw JSON.stringify comparison would spuriously flag a
82
+ // semantically-unchanged recipient as "changed" (found by the task
83
+ // review, reproduced live). Arrays keep their own order (order is
84
+ // semantically meaningful there — e.g. subprocessorChain); only OBJECT
85
+ // key order is normalized.
86
+ function _canonicalize(value) {
87
+ if (Array.isArray(value)) return value.map(_canonicalize);
88
+ if (value && typeof value === 'object') {
89
+ const sorted = {};
90
+ for (const key of Object.keys(value).sort()) sorted[key] = _canonicalize(value[key]);
91
+ return sorted;
92
+ }
93
+ return value;
94
+ }
95
+
96
+ function _diffRecipients(currentRecipients, patchRecipients) {
97
+ const added = [];
98
+ const removed = [];
99
+ const changed = [];
100
+ const currentKeys = new Set(Object.keys(currentRecipients));
101
+ for (const [key, value] of Object.entries(patchRecipients)) {
102
+ if (value === null) {
103
+ if (currentKeys.has(key)) removed.push(key);
104
+ // deleting a key that never existed is a no-op, not reported
105
+ continue;
106
+ }
107
+ if (!currentKeys.has(key)) { added.push(key); continue; }
108
+ if (JSON.stringify(_canonicalize(currentRecipients[key])) !== JSON.stringify(_canonicalize(value))) {
109
+ changed.push({ key, before: currentRecipients[key], after: value });
110
+ }
111
+ }
112
+ added.sort();
113
+ removed.sort();
114
+ changed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
115
+ return { added, removed, changed };
116
+ }
117
+
118
+ /**
119
+ * Propose a patch to a recipient-profiles.json-shaped config. Pure,
120
+ * never throws, never touches the filesystem. `currentConfig`/`patch`
121
+ * are both expected to be `{recipients: {...}}`-shaped — a malformed
122
+ * top-level shape on EITHER side (missing `recipients`, `recipients`
123
+ * not a plain object, or an empty-string/`__proto__` recipient key) is
124
+ * a validation failure (`valid: false`, `merged: null`), never a
125
+ * silent empty-object fallback. The stored config gets this treatment
126
+ * so an unrecognized shape (e.g. a typo'd top-level key) refuses to
127
+ * write rather than being silently treated as "nothing here yet" and
128
+ * overwritten; the patch gets it because it is user input the command's
129
+ * whole job is to validate.
130
+ *
131
+ * `patch.recipients` is an RFC-7396-style JSON MERGE PATCH against
132
+ * `currentConfig.recipients`, keyed at the recipient level — never a
133
+ * full replacement of the whole object: a key present with a non-null
134
+ * value REPLACES that key's entire entry (never deep-merged within
135
+ * itself); a key present with value `null` DELETES it (the only way to
136
+ * remove a recipient); a key the patch never mentions is left
137
+ * untouched in the merged result. Every OTHER top-level key on the
138
+ * current config (e.g. `$schema`, `version`) is preserved verbatim into
139
+ * `merged` — only `recipients` itself is merged.
140
+ *
141
+ * Returns `{valid, errors, diff, merged}` — `diff`/`merged` are always
142
+ * computed when both container shapes are valid, even when `valid` is
143
+ * false due to a per-entry error, so an operator can see what they
144
+ * attempted before fixing a validation error. `merged` is `null` only
145
+ * when there is no safe merge base to compute (a container-shape
146
+ * failure on either side). `merged`, when non-null, is the full
147
+ * config-shaped result the caller should write — never the raw patch.
148
+ */
149
+ export function proposeGovernanceEdit(currentConfig, patch) {
150
+ const currentContainerErrors = _validateContainerShape(currentConfig?.recipients, 'the current config file');
151
+ const patchContainerErrors = _validateContainerShape(patch?.recipients, 'the --patch file');
152
+ if (currentContainerErrors.length || patchContainerErrors.length) {
153
+ return {
154
+ valid: false,
155
+ errors: [...currentContainerErrors, ...patchContainerErrors],
156
+ diff: { added: [], removed: [], changed: [] },
157
+ merged: null,
158
+ };
159
+ }
160
+ const currentRecipients = currentConfig.recipients;
161
+ const patchRecipients = patch.recipients;
162
+ const entryErrors = _validateEntries(patchRecipients);
163
+ const diff = _diffRecipients(currentRecipients, patchRecipients);
164
+ const mergedRecipients = _mergeRecipients(currentRecipients, patchRecipients);
165
+ // Preserve every OTHER top-level key from the current config verbatim
166
+ // (e.g. $schema, version) — only `recipients` itself is merged.
167
+ const merged = { ...(currentConfig && typeof currentConfig === 'object' ? currentConfig : {}), recipients: mergedRecipients };
168
+ return { valid: entryErrors.length === 0, errors: entryErrors, diff, merged };
169
+ }