@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.
- package/CHANGELOG.md +139 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- 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
|
+
}
|