@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,417 @@
1
+ // remediation.js — M5 deliverable #6 (Blast-Radius: Remediation Command
2
+ // Center, FR-507 + AC-31): the pure RemediationItem contract, its event
3
+ // fold, its state machine, and the verification-evidence decision.
4
+ //
5
+ // Pure by contract — zero imports, no fs, no I/O of any kind, never
6
+ // throws. The ledger layer (`posture/remediation-ledger.js`, Task 2 of
7
+ // this same sub-project) owns every side effect: file locking, append,
8
+ // tolerant read, hash chaining. This module mirrors `governance-edit.js`'s
9
+ // own split (that module's own header makes the identical claim for its
10
+ // own CLI-write boundary) and the "pure data + pure functions, boundary-
11
+ // tested" precedent `flow-grade.js`/`obligation-mapping.js` already set
12
+ // in this package.
13
+ //
14
+ // ── The `verified`-unreachable-from-`state_changed` rule (AC-31) ──────
15
+ //
16
+ // AC-31's own `then`-clause requires that marking remediation work
17
+ // "done" can NEVER, by itself, mark a finding verified — only a real
18
+ // scan re-confirming the flow is gone (`scan_verification`) or an
19
+ // explicitly-permitted, explicitly-attributed manual attestation
20
+ // (`manual_attestation`) may transition an item into `verified`. PRD
21
+ // line 171 states the same rule as an explicit non-goal: this deliverable
22
+ // must never let "I fixed it" (a human claim) substitute for "the scanner
23
+ // re-observed the flow is gone" (an evidence-backed claim) without an
24
+ // explicit, auditable manual-attestation opt-in. `validateTransition`
25
+ // enforces this by rejecting `state_changed` with `state: 'verified'`
26
+ // FIRST, before even looking at the item's current state — the rejection
27
+ // is therefore genuinely unconditional, not just unreached from the
28
+ // states this module happens to reach today.
29
+ //
30
+ // ── Deliberate scope narrowing in evaluateVerificationEvidence ────────
31
+ //
32
+ // A required-evidence flow id is treated as SATISFIED only when it
33
+ // appears in `diff.removed.flows` with `causeClassification ===
34
+ // 'application_change'` — i.e. the flow is genuinely gone from the graph
35
+ // and its disappearance is attributable to an application change, not to
36
+ // the scanner simply seeing less than it used to. A flow that is still
37
+ // PRESENT in the graph but has merely become MORE PROTECTED (e.g. its
38
+ // `protectionSummary` moved from `unprotected` to `protected` — visible
39
+ // only as a `changed.flows` entry, never a `removed.flows` entry) is
40
+ // explicitly OUT OF SCOPE for this first cut: recognizing an "improving
41
+ // transition" as evidence of a fix needs its own deliberate, disclosed
42
+ // heuristic (what counts as "protected enough", whether a policy verdict
43
+ // change alone should count, etc.) that this module does not attempt.
44
+ // A future increment could add it; until then, such a flow reports
45
+ // `flows_still_present`, same as a flow with no change at all.
46
+ //
47
+ // `possible_coverage_regression` is computed DIFF-WIDE by
48
+ // `computeGraphDiff` (see `graph-diff.js:328`'s own
49
+ // `coverageRegressionReasons`/`flowRemovalCause` — every removed flow in
50
+ // one diff shares the SAME cause once any completeness signal regressed)
51
+ // — which is why a coverage-regression hit on any of the item's OWN
52
+ // required-evidence flows refuses the WHOLE verification immediately (PRD
53
+ // line 1975), rather than letting other, seemingly-clean required-
54
+ // evidence flows in the same diff verify: an incomplete scan cannot be
55
+ // trusted to have honestly seen everything it claims to have not seen.
56
+ // (The loop below only ever inspects `diff.removed.flows` entries for
57
+ // flows actually named in `requiredEvidenceFlowIds` — an unrelated
58
+ // removed flow elsewhere in the same diff is never consulted.)
59
+
60
+ export const REMEDIATION_STATES = Object.freeze([
61
+ 'open', 'in_progress', 'awaiting_verification', 'verified', 'accepted_risk', 'reopened',
62
+ ]);
63
+
64
+ export const REMEDIATION_EVENT_TYPES = Object.freeze([
65
+ 'opened', 'state_changed', 'scan_verification', 'manual_attestation', 'accepted_risk', 'reopened',
66
+ ]);
67
+
68
+ // PRD line 572's exact four-field list (scoping doc §4.3).
69
+ export const ACCEPTED_RISK_REQUIRED_FIELDS = Object.freeze(['approver', 'reason', 'scope', 'expiration']);
70
+
71
+ function _isNonEmptyString(v) {
72
+ return typeof v === 'string' && v.length > 0;
73
+ }
74
+
75
+ function _isPlainObject(v) {
76
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
77
+ }
78
+
79
+ // ── foldRemediationItem / foldRemediationLedger ────────────────────────
80
+
81
+ /**
82
+ * Folds an ordered event list for ONE remediation item into its current
83
+ * shape, or `null` for an empty/malformed/non-`opened`-first list. Never
84
+ * throws.
85
+ */
86
+ export function foldRemediationItem(events) {
87
+ if (!Array.isArray(events) || events.length === 0) return null;
88
+ const openEvent = events[0];
89
+ if (!openEvent || openEvent.type !== 'opened') return null;
90
+
91
+ const item = {
92
+ id: openEvent.id,
93
+ state: 'open',
94
+ owner: openEvent.owner,
95
+ dueDate: openEvent.dueDate,
96
+ recommendedControl: openEvent.recommendedControl,
97
+ assessment: openEvent.assessment,
98
+ affectedFlowIds: openEvent.affectedFlowIds,
99
+ affectedNodeIds: openEvent.affectedNodeIds,
100
+ affectedEdgeIds: openEvent.affectedEdgeIds,
101
+ requiredEvidence: openEvent.requiredEvidence,
102
+ manualAttestationPermitted: openEvent.manualAttestationPermitted,
103
+ approvals: [],
104
+ exceptions: [],
105
+ verificationSnapshotId: null,
106
+ history: events,
107
+ };
108
+
109
+ for (let i = 1; i < events.length; i++) {
110
+ const ev = events[i];
111
+ if (!ev || typeof ev !== 'object') continue;
112
+ switch (ev.type) {
113
+ case 'state_changed':
114
+ item.state = ev.state;
115
+ break;
116
+ case 'scan_verification':
117
+ if (ev.outcome === 'verified') {
118
+ item.state = 'verified';
119
+ item.verificationSnapshotId = ev.snapshotId;
120
+ }
121
+ // an 'unverifiable' outcome changes neither state nor snapshot.
122
+ break;
123
+ case 'manual_attestation':
124
+ item.state = 'verified';
125
+ item.approvals.push({
126
+ approver: ev.approver, reason: ev.reason, at: ev.at, evidenceKind: 'manual',
127
+ });
128
+ // Records a real baseline (final-review fix round 1, Blocking-3):
129
+ // without this, a manually-attested item keeps whatever STALE
130
+ // `verificationSnapshotId` it happened to carry (or `null`,
131
+ // falling back to an even less defensible baseline) forever, and
132
+ // `reopen-check` keeps diffing from that stale anchor — reopening
133
+ // a just-permitted attestation on the very next run even though
134
+ // nothing changed. `ev.snapshotId` is optional (a manual
135
+ // attestation before any lineage scan has ever run is legitimate).
136
+ if (ev.snapshotId) item.verificationSnapshotId = ev.snapshotId;
137
+ break;
138
+ case 'accepted_risk':
139
+ item.state = 'accepted_risk';
140
+ item.exceptions.push({
141
+ approver: ev.approver, reason: ev.reason, scope: ev.scope, expiration: ev.expiration, at: ev.at,
142
+ });
143
+ break;
144
+ case 'reopened':
145
+ item.state = 'reopened';
146
+ // Retires the stale anchor (final-review fix round 1, Blocking-3)
147
+ // so it cannot outlive the verification it belonged to — the next
148
+ // verification (scan or manual) must establish its own baseline.
149
+ item.verificationSnapshotId = null;
150
+ break;
151
+ default:
152
+ // an unrecognized event type is ignored by the fold — validation
153
+ // of proposed events is validateTransition's job, not this one's.
154
+ break;
155
+ }
156
+ }
157
+
158
+ return item;
159
+ }
160
+
161
+ /**
162
+ * Groups a flat, interleaved event stream by item id (`ev.itemId`,
163
+ * falling back to `ev.id` on an `opened` event that carries only `id`),
164
+ * preserving per-group insertion order, and folds each group. Events with
165
+ * no resolvable item id are skipped. Groups that fold to `null` are
166
+ * dropped. Never throws.
167
+ */
168
+ export function foldRemediationLedger(allEvents) {
169
+ const result = {};
170
+ if (!Array.isArray(allEvents)) return result;
171
+
172
+ const groups = new Map();
173
+ for (const ev of allEvents) {
174
+ if (!ev || typeof ev !== 'object') continue;
175
+ const itemId = ev.itemId ?? (ev.type === 'opened' ? ev.id : undefined);
176
+ if (!_isNonEmptyString(itemId)) continue;
177
+ if (!groups.has(itemId)) groups.set(itemId, []);
178
+ groups.get(itemId).push(ev);
179
+ }
180
+
181
+ for (const [itemId, events] of groups) {
182
+ const item = foldRemediationItem(events);
183
+ if (item !== null) result[itemId] = item;
184
+ }
185
+
186
+ return result;
187
+ }
188
+
189
+ // ── validateOpenPayload ─────────────────────────────────────────────────
190
+
191
+ const _REQUIRED_ASSESSMENT_FIELDS = [
192
+ 'assessmentId', 'targetId', 'targetKind', 'traceKind', 'scope', 'graphId', 'graphDigest', 'snapshotId',
193
+ ];
194
+
195
+ const _DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
196
+
197
+ /**
198
+ * Validates an `opened`-event-shaped payload. Returns `{valid, errors}`,
199
+ * `errors` as `[{field, message}]`. Never throws.
200
+ */
201
+ export function validateOpenPayload(payload) {
202
+ const errors = [];
203
+ const err = (field, message) => errors.push({ field, message });
204
+
205
+ if (!_isPlainObject(payload)) {
206
+ err('(payload)', 'payload must be an object');
207
+ return { valid: false, errors };
208
+ }
209
+
210
+ for (const field of ['id', 'owner', 'recommendedControl']) {
211
+ if (!_isNonEmptyString(payload[field])) err(field, `${field} is required and must be a non-empty string`);
212
+ }
213
+
214
+ if (!_isNonEmptyString(payload.dueDate) || !_DATE_RE.test(payload.dueDate)) {
215
+ err('dueDate', 'dueDate is required and must be a YYYY-MM-DD date');
216
+ }
217
+
218
+ if (!_isPlainObject(payload.assessment)) {
219
+ err('assessment', 'assessment is required and must be an object');
220
+ } else {
221
+ for (const field of _REQUIRED_ASSESSMENT_FIELDS) {
222
+ if (!_isNonEmptyString(payload.assessment[field])) {
223
+ err(`assessment.${field}`, `assessment.${field} is required and must be a non-empty string`);
224
+ }
225
+ }
226
+ // assessmentPath is the one OPTIONAL inline field — no check.
227
+ }
228
+
229
+ const evidence = payload.requiredEvidence;
230
+ if (!Array.isArray(evidence) || evidence.length === 0 || !evidence.every(_isNonEmptyString)) {
231
+ err('requiredEvidence', 'requiredEvidence is required and must be a non-empty array of non-empty strings');
232
+ }
233
+
234
+ return { valid: errors.length === 0, errors };
235
+ }
236
+
237
+ // ── validateTransition — AC-31's own state machine ──────────────────────
238
+
239
+ /**
240
+ * Validates a proposed event against a remediation item's current state
241
+ * (or `null` for no item yet). Returns `{valid, errors}`, `errors` as
242
+ * `[{field, message}]`. Never throws.
243
+ */
244
+ export function validateTransition(item, proposedEvent) {
245
+ const errors = [];
246
+ const err = (field, message) => errors.push({ field, message });
247
+
248
+ if (!_isPlainObject(proposedEvent)) {
249
+ err('(event)', 'proposed event must be an object');
250
+ return { valid: false, errors };
251
+ }
252
+
253
+ const type = proposedEvent.type;
254
+
255
+ if (type === 'opened') {
256
+ if (item !== null && item !== undefined) {
257
+ err('type', 'a remediation item with this id already exists — opened is only valid when no item exists');
258
+ return { valid: false, errors };
259
+ }
260
+ return { valid: true, errors: [] };
261
+ }
262
+
263
+ // AC-31's own load-bearing rule: state_changed can never reach
264
+ // `verified`. Checked FIRST, before even the "item must exist" check
265
+ // below, so the rejection is genuinely unconditional — reachable from
266
+ // no state at all, including no item — and cannot be routed around by
267
+ // any current state. See this file's own header.
268
+ if (type === 'state_changed' && proposedEvent.state === 'verified') {
269
+ err('state', 'state_changed can never reach verified — only scan_verification or manual_attestation may');
270
+ return { valid: false, errors };
271
+ }
272
+
273
+ // Every other event type requires an existing item.
274
+ if (!_isPlainObject(item)) {
275
+ err('type', `${type} is not valid against no item — open the item first`);
276
+ return { valid: false, errors };
277
+ }
278
+
279
+ switch (type) {
280
+ case 'state_changed': {
281
+ if (proposedEvent.state === 'in_progress') {
282
+ if (item.state !== 'open' && item.state !== 'reopened') {
283
+ err('state', `state_changed to in_progress is only valid from open or reopened, not ${item.state}`);
284
+ }
285
+ } else if (proposedEvent.state === 'awaiting_verification') {
286
+ if (item.state !== 'in_progress') {
287
+ err('state', `state_changed to awaiting_verification is only valid from in_progress, not ${item.state}`);
288
+ }
289
+ } else {
290
+ err('state', `state_changed to ${JSON.stringify(proposedEvent.state)} is not a recognized transition`);
291
+ }
292
+ break;
293
+ }
294
+
295
+ case 'scan_verification': {
296
+ if (item.state !== 'awaiting_verification') {
297
+ err('type', `scan_verification is only valid from awaiting_verification, not ${item.state}`);
298
+ break;
299
+ }
300
+ if (proposedEvent.outcome === 'verified') {
301
+ if (!_isNonEmptyString(proposedEvent.snapshotId)) {
302
+ err('snapshotId', 'a verified outcome requires a snapshotId');
303
+ }
304
+ } else if (proposedEvent.outcome === 'unverifiable') {
305
+ // no further requirement beyond the outcome itself.
306
+ } else {
307
+ err('outcome', `outcome ${JSON.stringify(proposedEvent.outcome)} is not a recognized scan_verification outcome`);
308
+ }
309
+ break;
310
+ }
311
+
312
+ case 'manual_attestation': {
313
+ if (item.state !== 'awaiting_verification') {
314
+ err('type', `manual_attestation is only valid from awaiting_verification, not ${item.state}`);
315
+ break;
316
+ }
317
+ if (!item.manualAttestationPermitted) {
318
+ err('manualAttestationPermitted', 'manual attestation is not permitted for this item — it must be opened with --allow-manual-attestation to allow one (open a new item if this one predates that need)');
319
+ break;
320
+ }
321
+ if (!_isNonEmptyString(proposedEvent.approver)) err('approver', 'manual_attestation requires a non-empty approver');
322
+ if (!_isNonEmptyString(proposedEvent.reason)) err('reason', 'manual_attestation requires a non-empty reason');
323
+ break;
324
+ }
325
+
326
+ case 'accepted_risk': {
327
+ if (item.state === 'verified' || item.state === 'accepted_risk') {
328
+ err('type', `accepted_risk is not valid from ${item.state}`);
329
+ break;
330
+ }
331
+ for (const field of ACCEPTED_RISK_REQUIRED_FIELDS) {
332
+ if (!_isNonEmptyString(proposedEvent[field])) {
333
+ err(field, `accepted_risk requires a non-empty ${field}`);
334
+ }
335
+ }
336
+ break;
337
+ }
338
+
339
+ case 'reopened': {
340
+ if (item.state !== 'verified') {
341
+ err('type', `reopened is only valid from verified, not ${item.state}`);
342
+ break;
343
+ }
344
+ if (!_isNonEmptyString(proposedEvent.reason)) {
345
+ err('reason', 'reopened must always carry a non-empty reason naming what triggered it');
346
+ }
347
+ break;
348
+ }
349
+
350
+ default:
351
+ err('type', `${JSON.stringify(type)} is not a recognized remediation event type`);
352
+ break;
353
+ }
354
+
355
+ return { valid: errors.length === 0, errors };
356
+ }
357
+
358
+ // ── evaluateVerificationEvidence ────────────────────────────────────────
359
+
360
+ /**
361
+ * Decides whether a GraphDiff's real, checkable evidence satisfies every
362
+ * flow id a remediation item required evidence for. See this file's own
363
+ * header for the deliberate scope narrowing and the coverage-regression
364
+ * whole-verification-refusal rule. Never throws.
365
+ */
366
+ export function evaluateVerificationEvidence(diff, requiredEvidenceFlowIds) {
367
+ if (!Array.isArray(requiredEvidenceFlowIds) || requiredEvidenceFlowIds.length === 0) {
368
+ return { outcome: 'unverifiable', reason: 'no_required_evidence' };
369
+ }
370
+
371
+ const removedFlows = diff?.removed?.flows;
372
+ const removedById = new Map();
373
+ if (Array.isArray(removedFlows)) {
374
+ for (const entry of removedFlows) {
375
+ if (entry && typeof entry.id === 'string') removedById.set(entry.id, entry);
376
+ }
377
+ }
378
+
379
+ const unsatisfiedFlowIds = [];
380
+
381
+ for (const flowId of requiredEvidenceFlowIds) {
382
+ const entry = removedById.get(flowId);
383
+ const cause = entry?.causeClassification;
384
+
385
+ if (cause === 'possible_coverage_regression') {
386
+ return {
387
+ outcome: 'unverifiable',
388
+ reason: 'possible_coverage_regression',
389
+ flowId,
390
+ coverageRegressionReasons: entry.coverageRegressionReasons,
391
+ };
392
+ }
393
+
394
+ if (cause === 'reidentified') {
395
+ return {
396
+ outcome: 'unverifiable',
397
+ reason: 'reidentified',
398
+ flowId,
399
+ reidentifiedTo: entry.reidentifiedTo,
400
+ };
401
+ }
402
+
403
+ if (cause === 'application_change') {
404
+ continue; // satisfied
405
+ }
406
+
407
+ // Absent from removed.flows entirely, or an unrecognized
408
+ // classification — either way, not satisfied.
409
+ unsatisfiedFlowIds.push(flowId);
410
+ }
411
+
412
+ if (unsatisfiedFlowIds.length > 0) {
413
+ return { outcome: 'unverifiable', reason: 'flows_still_present', unsatisfiedFlowIds };
414
+ }
415
+
416
+ return { outcome: 'verified' };
417
+ }
@@ -0,0 +1,91 @@
1
+ //
2
+ // resolve-destination.js — Milestone 2, Sub-project A ("external destination
3
+ // resolver"), increment 1.
4
+ //
5
+ // Implements DESIGN_DESTINATION_RESOLVER.md's `destination` object shape and
6
+ // its two real resolution rules (literal, dynamic) — see that file for the
7
+ // full field contract and the explicit list of what is deferred to later
8
+ // increments (constant-folding, config-chain resolution, schema
9
+ // correlation, runtime corroboration, AI-provider/model resolution).
10
+ //
11
+ // Reuse boundary: this module adds NO new detection of its own. It composes
12
+ // three primitives `coverage.js` already shipped for FR-203 (Sub-project E,
13
+ // increment 4) — `detectUnresolvedDestination`, `renderExpr`, and the
14
+ // `FR203_ARG0_DESTINATION_CATEGORIES` eligibility set — rather than
15
+ // re-deriving a second, potentially-drifting notion of "is this call site's
16
+ // destination statically nameable". Importing `coverage.js` here and having
17
+ // `coverage.js` import `resolveDestination` back (Task 4, wiring this as
18
+ // `buildGraphWithCoverage`'s default `opts.resolveDestination`) makes the
19
+ // two files mutually dependent — a real ES module cycle, not an accident.
20
+ // It is safe here specifically because neither module's own top-level body
21
+ // ever READS a value imported from the other; every cross-module reference
22
+ // below is inside a function body, resolved only once `resolveDestination`
23
+ // is actually CALLED, by which point module evaluation (both files) has
24
+ // long finished. `detectUnresolvedDestination`/`renderExpr` are function
25
+ // declarations (hoisted with a real value before either file's top-level
26
+ // body runs at all); `FR203_ARG0_DESTINATION_CATEGORIES` is a `const`, only
27
+ // ever referenced inside `resolveDestination`'s own body, never at this
28
+ // file's top level.
29
+
30
+ import { detectUnresolvedDestination, renderExpr, FR203_ARG0_DESTINATION_CATEGORIES } from './coverage.js';
31
+
32
+ function isLiteral(e) {
33
+ return Boolean(e) && typeof e === 'object' && e.kind === 'literal';
34
+ }
35
+
36
+ const UNKNOWN = Object.freeze({ resolutionStatus: 'unknown', raw: null, literalValue: null, blockingExpression: null });
37
+
38
+ /**
39
+ * `resolveDestination(site)` — the `opts.resolveDestination` hook
40
+ * `graph-builder.js` (Task 3) and `coverage.js`'s `buildGraphWithCoverage`
41
+ * (Task 4) wire in. `site` is the same shape `detectUnresolvedDestination`/
42
+ * `resolveSiteDecision` already consume: `site.calleeExpr`, `site.args`,
43
+ * and (when available) `site.decision.category` — the registry category
44
+ * `FR203_ARG0_DESTINATION_CATEGORIES` gates against. Never throws, even on
45
+ * a malformed/missing `site` (mirrors `detectUnresolvedDestination`'s own
46
+ * defensiveness).
47
+ *
48
+ * Exactly three outcomes (DESIGN_DESTINATION_RESOLVER.md §2):
49
+ *
50
+ * 1. `'literal'` — the destination-bearing argument (arg0, but ONLY when
51
+ * `site.decision.category` is one of `FR203_ARG0_DESTINATION_CATEGORIES`
52
+ * — everywhere else arg0 is a PAYLOAD, not a destination, exactly the
53
+ * distinction MUST-FIX 1 already drew for FR-203) or the call's receiver
54
+ * (a member callee's `object`) is a `literal`-kind expression.
55
+ * 2. `'dynamic'` — anything `detectUnresolvedDestination(site)` already
56
+ * flags as unresolvable, respecting the SAME arg0 category gate: a
57
+ * `via: 'arg0'` result only counts when the category is eligible, or it
58
+ * would reintroduce the exact false-positive MUST-FIX 1 fixed (a
59
+ * non-literal SQL/HTML payload argument on a database/client-storage
60
+ * call site is not a "dynamic destination", it's an ordinary payload).
61
+ * A `via: 'receiver'` result always counts — the receiver signal has no
62
+ * narrower gate in FR-203 either.
63
+ * 3. `'unknown'` — everything else: a plain identifier or plain member
64
+ * chain FR-203 itself doesn't flag, or a non-eligible-category literal
65
+ * argument. Deliberately the same answer Milestone 1 always gave.
66
+ */
67
+ export function resolveDestination(site) {
68
+ if (!site || typeof site !== 'object') return UNKNOWN;
69
+
70
+ const category = site.decision && typeof site.decision === 'object' ? site.decision.category : null;
71
+ const argEligible = typeof category === 'string' && FR203_ARG0_DESTINATION_CATEGORIES.includes(category);
72
+
73
+ const args = Array.isArray(site.args) ? site.args : [];
74
+ const arg0 = args[0];
75
+ if (argEligible && isLiteral(arg0)) {
76
+ return { resolutionStatus: 'literal', raw: renderExpr(arg0), literalValue: String(arg0.value), blockingExpression: null };
77
+ }
78
+
79
+ const callee = site.calleeExpr;
80
+ const receiver = callee && typeof callee === 'object' && callee.kind === 'member' ? callee.object : null;
81
+ if (isLiteral(receiver)) {
82
+ return { resolutionStatus: 'literal', raw: renderExpr(receiver), literalValue: String(receiver.value), blockingExpression: null };
83
+ }
84
+
85
+ const unresolved = detectUnresolvedDestination(site);
86
+ if (unresolved && (unresolved.via !== 'arg0' || argEligible)) {
87
+ return { resolutionStatus: 'dynamic', raw: unresolved.blockingExpression, literalValue: null, blockingExpression: unresolved.blockingExpression };
88
+ }
89
+
90
+ return UNKNOWN;
91
+ }