@crediolabs/policy-synth 0.2.0 → 0.3.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 (184) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/interpreter/adapter.d.ts +19 -25
  3. package/dist/adapters/interpreter/adapter.js +54 -487
  4. package/dist/adapters/interpreter/index.d.ts +1 -1
  5. package/dist/adapters/interpreter/index.js +1 -1
  6. package/dist/adapters/oz/adapter.js +1 -15
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/index.d.ts +1 -4
  9. package/dist/index.js +1 -4
  10. package/dist/install/build-add-context-rule.d.ts +3 -19
  11. package/dist/install/build-add-context-rule.js +3 -98
  12. package/dist/install/build-install-policy.d.ts +4 -53
  13. package/dist/install/build-install-policy.js +44 -123
  14. package/dist/install/index.d.ts +0 -2
  15. package/dist/install/index.js +0 -7
  16. package/dist/ir/index.d.ts +1 -1
  17. package/dist/ir/types.d.ts +7 -76
  18. package/dist/ir/types.js +0 -2
  19. package/dist/predicate/decode.d.ts +1 -1
  20. package/dist/predicate/decode.js +2 -70
  21. package/dist/predicate/encode.js +45 -229
  22. package/dist/predicate/from-json.js +1 -42
  23. package/dist/record/decode.js +3 -8
  24. package/dist/review-card/builder.d.ts +4 -3
  25. package/dist/review-card/builder.js +18 -162
  26. package/dist/review-card/cross-check.js +2 -105
  27. package/dist/review-card/render-leaf.d.ts +4 -0
  28. package/dist/review-card/render-leaf.js +47 -0
  29. package/dist/run/index.d.ts +36 -139
  30. package/dist/run/index.js +125 -437
  31. package/dist/run/schemas.d.ts +168 -1929
  32. package/dist/run/schemas.js +40 -269
  33. package/dist/seams/types.d.ts +0 -2
  34. package/dist/simulate/deny-cases.d.ts +26 -0
  35. package/dist/simulate/deny-cases.js +282 -0
  36. package/dist/simulate/evaluate.d.ts +18 -0
  37. package/dist/simulate/evaluate.js +271 -0
  38. package/dist/simulate/index.d.ts +4 -0
  39. package/dist/simulate/index.js +8 -0
  40. package/dist/synth/compose-from-recording.d.ts +24 -51
  41. package/dist/synth/compose-from-recording.js +120 -282
  42. package/dist/synth/deny-cases.d.ts +5 -7
  43. package/dist/synth/deny-cases.js +23 -373
  44. package/dist/synth/evaluate.d.ts +0 -21
  45. package/dist/synth/evaluate.js +56 -336
  46. package/dist/synth/index.d.ts +0 -5
  47. package/dist/synth/index.js +0 -5
  48. package/dist/synth/permit-context.d.ts +2 -9
  49. package/dist/synth/permit-context.js +1 -82
  50. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  51. package/dist/synth/synthesize-from-recording.js +41 -356
  52. package/dist/types.d.ts +8 -60
  53. package/dist/types.js +5 -2
  54. package/dist/verify/simulate.d.ts +1 -14
  55. package/dist/verify/simulate.js +2 -88
  56. package/dist/verify/verify.d.ts +1 -7
  57. package/dist/verify/verify.js +0 -8
  58. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  59. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  60. package/dist-cjs/adapters/interpreter/index.d.ts +1 -1
  61. package/dist-cjs/adapters/interpreter/index.js +1 -2
  62. package/dist-cjs/adapters/oz/adapter.js +1 -15
  63. package/dist-cjs/errors.d.ts +1 -1
  64. package/dist-cjs/index.d.ts +1 -4
  65. package/dist-cjs/index.js +1 -4
  66. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  67. package/dist-cjs/install/build-add-context-rule.js +1 -97
  68. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  69. package/dist-cjs/install/build-install-policy.js +44 -122
  70. package/dist-cjs/install/index.d.ts +0 -2
  71. package/dist-cjs/install/index.js +2 -23
  72. package/dist-cjs/ir/index.d.ts +1 -1
  73. package/dist-cjs/ir/types.d.ts +7 -76
  74. package/dist-cjs/ir/types.js +0 -2
  75. package/dist-cjs/predicate/decode.d.ts +1 -1
  76. package/dist-cjs/predicate/decode.js +2 -70
  77. package/dist-cjs/predicate/encode.js +45 -229
  78. package/dist-cjs/predicate/from-json.js +1 -42
  79. package/dist-cjs/record/decode.js +3 -8
  80. package/dist-cjs/review-card/builder.d.ts +4 -3
  81. package/dist-cjs/review-card/builder.js +23 -167
  82. package/dist-cjs/review-card/cross-check.js +7 -110
  83. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  84. package/dist-cjs/review-card/render-leaf.js +52 -0
  85. package/dist-cjs/run/index.d.ts +36 -139
  86. package/dist-cjs/run/index.js +125 -444
  87. package/dist-cjs/run/schemas.d.ts +168 -1929
  88. package/dist-cjs/run/schemas.js +41 -270
  89. package/dist-cjs/seams/types.d.ts +0 -2
  90. package/dist-cjs/simulate/deny-cases.d.ts +26 -0
  91. package/dist-cjs/simulate/deny-cases.js +286 -0
  92. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  93. package/dist-cjs/simulate/evaluate.js +274 -0
  94. package/dist-cjs/simulate/index.d.ts +4 -0
  95. package/dist-cjs/simulate/index.js +13 -0
  96. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  97. package/dist-cjs/synth/compose-from-recording.js +120 -282
  98. package/dist-cjs/synth/deny-cases.d.ts +5 -7
  99. package/dist-cjs/synth/deny-cases.js +23 -374
  100. package/dist-cjs/synth/evaluate.d.ts +0 -21
  101. package/dist-cjs/synth/evaluate.js +57 -337
  102. package/dist-cjs/synth/index.d.ts +0 -5
  103. package/dist-cjs/synth/index.js +1 -11
  104. package/dist-cjs/synth/permit-context.d.ts +2 -9
  105. package/dist-cjs/synth/permit-context.js +1 -82
  106. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  107. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  108. package/dist-cjs/types.d.ts +8 -60
  109. package/dist-cjs/types.js +6 -3
  110. package/dist-cjs/verify/simulate.d.ts +1 -14
  111. package/dist-cjs/verify/simulate.js +2 -88
  112. package/dist-cjs/verify/verify.d.ts +1 -7
  113. package/dist-cjs/verify/verify.js +0 -8
  114. package/package.json +4 -4
  115. package/src/adapters/interpreter/adapter.ts +76 -572
  116. package/src/errors.ts +0 -19
  117. package/src/index.ts +1 -4
  118. package/src/install/build-add-context-rule.ts +5 -139
  119. package/src/install/build-install-policy.ts +93 -214
  120. package/src/install/index.ts +0 -34
  121. package/src/predicate/decode.ts +2 -70
  122. package/src/predicate/encode.ts +49 -261
  123. package/src/predicate/from-json.ts +1 -43
  124. package/src/record/decode.ts +3 -8
  125. package/src/review-card/builder.ts +19 -168
  126. package/src/review-card/cross-check.ts +3 -105
  127. package/src/review-card/render-leaf.ts +48 -0
  128. package/src/run/index.ts +144 -572
  129. package/src/run/schemas.ts +42 -291
  130. package/src/simulate/deny-cases.ts +334 -0
  131. package/src/simulate/evaluate.ts +284 -0
  132. package/src/simulate/index.ts +11 -0
  133. package/src/synth/compose-from-recording.ts +148 -347
  134. package/src/synth/index.ts +0 -13
  135. package/src/synth/synthesize-from-recording.ts +43 -456
  136. package/src/types.ts +13 -49
  137. package/dist/install/authority-overlap.d.ts +0 -134
  138. package/dist/install/authority-overlap.js +0 -0
  139. package/dist/install/build-install-predicate.d.ts +0 -96
  140. package/dist/install/build-install-predicate.js +0 -444
  141. package/dist/install/build-merge-policy.d.ts +0 -70
  142. package/dist/install/build-merge-policy.js +0 -130
  143. package/dist/install/plan-merge-policy.d.ts +0 -49
  144. package/dist/install/plan-merge-policy.js +0 -86
  145. package/dist/install/read-account-rules.d.ts +0 -100
  146. package/dist/install/read-account-rules.js +0 -283
  147. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  148. package/dist-cjs/install/authority-overlap.js +0 -0
  149. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  150. package/dist-cjs/install/build-install-predicate.js +0 -479
  151. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  152. package/dist-cjs/install/build-merge-policy.js +0 -134
  153. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  154. package/dist-cjs/install/plan-merge-policy.js +0 -90
  155. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  156. package/dist-cjs/install/read-account-rules.js +0 -296
  157. package/src/adapters/interpreter/index.ts +0 -8
  158. package/src/adapters/oz/adapter.ts +0 -376
  159. package/src/adapters/oz/index.ts +0 -9
  160. package/src/codegen/compile-gate.ts +0 -167
  161. package/src/codegen/index.ts +0 -17
  162. package/src/codegen/template.ts +0 -165
  163. package/src/install/authority-overlap.ts +0 -0
  164. package/src/install/build-merge-policy.ts +0 -219
  165. package/src/install/plan-merge-policy.ts +0 -133
  166. package/src/install/read-account-rules.ts +0 -376
  167. package/src/ir/index.ts +0 -13
  168. package/src/ir/types.ts +0 -132
  169. package/src/mandate/index.ts +0 -4
  170. package/src/mandate/to-ir.ts +0 -71
  171. package/src/mandate/types.ts +0 -21
  172. package/src/seams/index.ts +0 -11
  173. package/src/seams/types.ts +0 -81
  174. package/src/synth/deny-cases.ts +0 -663
  175. package/src/synth/evaluate.ts +0 -613
  176. package/src/synth/harness.ts +0 -68
  177. package/src/synth/minimize.ts +0 -48
  178. package/src/synth/permit-context.ts +0 -136
  179. package/src/synth/predicate-literals.ts +0 -27
  180. package/src/synth/synthesize-from-mandate.ts +0 -82
  181. package/src/verify/envelope.ts +0 -28
  182. package/src/verify/index.ts +0 -5
  183. package/src/verify/simulate.ts +0 -311
  184. package/src/verify/verify.ts +0 -243
@@ -1,663 +0,0 @@
1
- import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
2
- import { MAX_SCVAL_CLONE_DEPTH } from '../types.ts'
3
- import type { EvalContext } from './evaluate.ts'
4
- import { literalNumericBigInt } from './predicate-literals.ts'
5
-
6
- export interface DenyCase {
7
- dimension: string
8
- ctx: EvalContext
9
- /** Canonical reason the Rust interpreter returns for this dimension.
10
- * Optional: when set, `runHarness` asserts the TS evaluator emits this
11
- * exact string, so a future TS/Rust reason divergence fails CI. When
12
- * absent the case is only checked on the boolean decision. */
13
- expectedReason?: string
14
- }
15
-
16
- export interface GeneratedCases {
17
- permit: EvalContext
18
- denies: DenyCase[]
19
- }
20
-
21
- type ComparisonOperator = 'eq' | 'lt' | 'lte' | 'gt' | 'gte'
22
-
23
- type ComparisonNode = {
24
- op: ComparisonOperator
25
- left: PredicateLeaf
26
- right: PredicateLeaf
27
- }
28
-
29
- type MembershipNode = {
30
- op: 'in'
31
- needle: PredicateLeaf
32
- haystack: PredicateLeaf[]
33
- }
34
-
35
- interface PredicateFacts {
36
- comparisons: ComparisonNode[]
37
- memberships: MembershipNode[]
38
- }
39
-
40
- const ORACLE_CASES = [
41
- ['oracle_stale', 'stale'],
42
- ['oracle_missing', 'missing'],
43
- ['oracle_deviation_exceeded', 'deviation'],
44
- ['oracle_paused', 'paused'],
45
- ] as const
46
-
47
- // Cross-layer reason contract: the TS evaluator's deny reason for each
48
- // oracle error category must match the Rust interpreter's `DenyReason` code
49
- // string (the same strings surface in the conformance fixture). The harness
50
- // asserts them via the per-case `expectedReason`; a future divergence
51
- // surfaces as a REASON_MISMATCH failure.
52
- const ORACLE_ERROR_REASON: Record<(typeof ORACLE_CASES)[number][1], string> = {
53
- stale: 'ORACLE_STALE',
54
- missing: 'ORACLE_MISSING',
55
- deviation: 'ORACLE_DEVIATION_EXCEEDED',
56
- paused: 'ORACLE_PAUSED',
57
- }
58
-
59
- // Deterministic XLM/USDC adjacency fixture; the shared registry can replace this boundary later.
60
- const ADJACENT_ASSETS = [
61
- 'CAS3J7GYLGXMF6TDJ5WQ2PEN4GRVNXJUIQ2TZU3ZB3OQ2V4DRCWI7WPF',
62
- 'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
63
- ] as const
64
-
65
- // Property-harness mutation dimensions excluded from the synth pipeline's
66
- // self-verify call so existing fixtures still emit policies. The harness tests
67
- // them as FINDINGS against the already-emitted policy.
68
- const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
69
-
70
- // The dimensions the synth pipeline uses for self-verify and minimise. No count
71
- // is written here on purpose: this comment said 15 while the array held 17, and
72
- // anyone checking the number should read the array rather than the prose.
73
- // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
74
- // alongside the existing dimensions so the per-element binds emitted for
75
- // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
76
- // minimise. Without these, no deny case exercises the new leaves and the
77
- // minimise pass drops them as "redundant" - reopening the element-append
78
- // hole they were added to close. Both dimensions are no-ops for any fixture
79
- // whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
80
- // generator short-circuits on leaf-kind), so they cannot make production
81
- // synthesis refuse for fixtures that do not exercise the new grammar.
82
- const ORIGINAL_DIMENSIONS: string[] = [
83
- 'amount',
84
- 'asset',
85
- 'contract',
86
- 'function',
87
- 'timing',
88
- 'time_window',
89
- 'invocation_count',
90
- 'arg_amount_bound',
91
- 'arg_bound',
92
- 'scope_contract_fn_arg',
93
- 'oracle_stale',
94
- 'oracle_missing',
95
- 'oracle_deviation_exceeded',
96
- 'oracle_paused',
97
- 'soroswap_allowed_path',
98
- 'vec_append',
99
- 'map_field_flip',
100
- ]
101
-
102
- export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS }
103
-
104
- /** Build deterministic model-evaluated alternatives without mutating the intended call.
105
- *
106
- * @param predicate - the synthesized predicate
107
- * @param permitCtx - EvalContext for the intended (permitted) call
108
- * @param dimensions - optional whitelist of dimension names to emit; when omitted
109
- * all known dimensions (including over-permissiveness mutations)
110
- * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
111
- * existing fixtures do not regress. */
112
- export function generateCases(
113
- predicate: PredicateNode,
114
- permitCtx: EvalContext,
115
- dimensions?: string[]
116
- ): GeneratedCases {
117
- const facts = inspectPredicate(predicate)
118
- const denies: DenyCase[] = []
119
-
120
- for (const comparison of facts.comparisons) {
121
- if (comparison.left.kind !== 'amount') continue
122
- const mutated = mutateBigIntRecord(
123
- permitCtx,
124
- 'amountByToken',
125
- comparison.left.token,
126
- comparison
127
- )
128
- if (mutated) denies.push({ dimension: 'amount', ctx: mutated, expectedReason: 'AMOUNT_BOUND' })
129
- }
130
-
131
- const movedTokens = new Set<string>()
132
- for (const comparison of facts.comparisons) {
133
- if (comparison.left.kind === 'amount' || comparison.left.kind === 'window_spent') {
134
- movedTokens.add(comparison.left.token)
135
- }
136
- }
137
- for (const token of movedTokens) {
138
- // The `asset` deny case mutates BOTH the amount record AND the contract
139
- // binding (token's value moved to an adjacent asset; the same swap applied
140
- // to any address-typed `call_arg` / `call_contract` literal). The actual
141
- // deny reason is therefore predicate-dependent - the order of evaluation
142
- // is CONTRACT_SCOPE (step 3) before AMOUNT_BOUND (step 6), so a policy
143
- // that has both bindings denies with CONTRACT_SCOPE, while a policy with
144
- // only an amount binding denies with AMOUNT_BOUND. The reason assertion
145
- // would be brittle here; the boolean decision is what we strictly need
146
- // to pin. Leave `expectedReason` unset for this dimension.
147
- denies.push({ dimension: 'asset', ctx: mutateAsset(predicate, permitCtx, token) })
148
- }
149
-
150
- const contractConstraints = [
151
- ...facts.comparisons.filter(
152
- (node) =>
153
- node.op === 'eq' &&
154
- node.left.kind === 'call_contract' &&
155
- node.right.kind === 'literal_address'
156
- ),
157
- ...facts.memberships.filter((node) => node.needle.kind === 'call_contract'),
158
- ]
159
- for (const _constraint of contractConstraints) {
160
- const ctx = cloneContext(permitCtx)
161
- ctx.contract = distinctText(permitCtx.contract, 'contract')
162
- denies.push({ dimension: 'contract', ctx, expectedReason: 'CONTRACT_SCOPE' })
163
- }
164
-
165
- const functionConstraints = [
166
- ...facts.comparisons.filter(
167
- (node) =>
168
- node.op === 'eq' && node.left.kind === 'call_fn' && node.right.kind === 'literal_symbol'
169
- ),
170
- ...facts.memberships.filter((node) => node.needle.kind === 'call_fn'),
171
- ]
172
- for (const _constraint of functionConstraints) {
173
- const ctx = cloneContext(permitCtx)
174
- ctx.fn = distinctText(permitCtx.fn, 'function')
175
- denies.push({ dimension: 'function', ctx, expectedReason: 'FN_MISMATCH' })
176
- }
177
-
178
- if (permitCtx.validUntilLedger !== undefined) {
179
- const ctx = cloneContext(permitCtx)
180
- ctx.atLedger = permitCtx.validUntilLedger + 1
181
- denies.push({ dimension: 'timing', ctx, expectedReason: 'EXPIRED' })
182
- }
183
-
184
- for (const comparison of facts.comparisons) {
185
- if (comparison.left.kind !== 'window_spent') continue
186
- const mutated = mutateBigIntRecord(
187
- permitCtx,
188
- 'windowSpentByToken',
189
- comparison.left.token,
190
- comparison,
191
- false
192
- )
193
- if (mutated)
194
- denies.push({ dimension: 'time_window', ctx: mutated, expectedReason: 'AMOUNT_BOUND' })
195
- }
196
-
197
- for (const comparison of facts.comparisons) {
198
- if (comparison.left.kind !== 'invocation_count_in_window') continue
199
- if (comparison.right.kind !== 'literal_u32') continue
200
- const ctx = cloneContext(permitCtx)
201
- ctx.invocationCountByWindow[comparison.left.windowSecs] = violatingNumber(
202
- comparison.op,
203
- comparison.right.value
204
- )
205
- denies.push({ dimension: 'invocation_count', ctx, expectedReason: 'FREQUENCY' })
206
- }
207
-
208
- // Ordered numeric bound on a call_arg (e.g. a SoroSwap input-amount cap
209
- // `call_arg[0] <= limit`). A violating deny case pushes the arg past the
210
- // bound so the leaf is exercised - and, critically, so `minimize` keeps it:
211
- // a conjunct that no deny case needs is pruned as redundant, which would
212
- // silently drop a caller-requested restriction.
213
- for (const comparison of facts.comparisons) {
214
- if (comparison.op === 'eq' || comparison.left.kind !== 'call_arg') continue
215
- const violating = violatingArgScVal(comparison.op, comparison.right)
216
- if (!violating) continue
217
- const ctx = cloneContext(permitCtx)
218
- ctx.args[comparison.left.index] = violating
219
- denies.push({ dimension: 'arg_amount_bound', ctx, expectedReason: 'ARG_MISMATCH' })
220
- }
221
-
222
- const argumentConstraints: Array<{ index: number }> = []
223
- for (const comparison of facts.comparisons) {
224
- if (
225
- comparison.op === 'eq' &&
226
- comparison.left.kind === 'call_arg' &&
227
- comparison.right.kind !== 'literal_vec'
228
- ) {
229
- argumentConstraints.push({ index: comparison.left.index })
230
- }
231
- }
232
- for (const membership of facts.memberships) {
233
- if (membership.needle.kind === 'call_arg') {
234
- argumentConstraints.push({ index: membership.needle.index })
235
- }
236
- }
237
- for (const constraint of argumentConstraints) {
238
- const ctx = cloneContext(permitCtx)
239
- ctx.args[constraint.index] = { type: 'other', value: 'deny-case-opaque-argument' }
240
- // The `arg_bound` case sets the arg to an opaque ScVal. The deny reason
241
- // depends on the predicate shape:
242
- // - an `eq(call_arg[i], literal)` denies with ARG_MISMATCH
243
- // - an `in(call_arg[i], [literals])` denies with NOT_IN_ALLOWLIST
244
- // (opaque needles fail-closed at the `in` membership gate, step 5)
245
- // Both are correct in the TS evaluator; the reason is predicate-dependent
246
- // so we cannot pin a single canonical reason here. The boolean decision
247
- // is what we strictly need to assert; the reason is recorded (not asserted)
248
- // when the harness runs this case.
249
- denies.push({ dimension: 'arg_bound', ctx })
250
- }
251
-
252
- const scopedArgumentIndices = new Set<number>(argumentConstraints.map(({ index }) => index))
253
- for (const comparison of facts.comparisons) {
254
- if (
255
- comparison.op === 'eq' &&
256
- comparison.left.kind === 'call_arg' &&
257
- comparison.right.kind === 'literal_vec'
258
- ) {
259
- scopedArgumentIndices.add(comparison.left.index)
260
- }
261
- }
262
- if (
263
- contractConstraints.length > 0 &&
264
- functionConstraints.length > 0 &&
265
- scopedArgumentIndices.size > 0
266
- ) {
267
- const ctx = cloneContext(permitCtx)
268
- ctx.contract = distinctText(permitCtx.contract, 'authorized-call-contract')
269
- ctx.fn = distinctText(permitCtx.fn, 'authorized-call-function')
270
- for (const index of scopedArgumentIndices) {
271
- ctx.args[index] = { type: 'other', value: 'deny-case-authorized-call-argument' }
272
- }
273
- // `scope_contract_fn_arg` changes contract, fn, AND args simultaneously.
274
- // The first failing child of the AND decides the reason, and the contract
275
- // check (step 3) does fire first in the evaluator. The reason is therefore
276
- // CONTRACT_SCOPE for the canonical case, but a predicate that lists the
277
- // call_fn leaf first (or that has a `call_arg_field` for that arg index)
278
- // can flip the order. Pin the assertion here only when no `in` /
279
- // call_arg_field binds the same arg index exist - the canonical blend
280
- // case does have those binds, so the assertion would fire there.
281
- denies.push({ dimension: 'scope_contract_fn_arg', ctx })
282
- }
283
-
284
- const oracleComparisons = facts.comparisons.filter((node) => node.left.kind === 'oracle_price')
285
- for (const [dimension, error] of ORACLE_CASES) {
286
- for (const comparison of oracleComparisons) {
287
- if (comparison.left.kind !== 'oracle_price') continue
288
- const ctx = cloneContext(permitCtx)
289
- ctx.oraclePriceByAsset[comparison.left.asset] = { error }
290
- denies.push({ dimension, ctx, expectedReason: ORACLE_ERROR_REASON[error] })
291
- }
292
- }
293
-
294
- for (const comparison of facts.comparisons) {
295
- if (
296
- comparison.op !== 'eq' ||
297
- comparison.left.kind !== 'call_arg' ||
298
- comparison.right.kind !== 'literal_vec'
299
- ) {
300
- continue
301
- }
302
- const ctx = cloneContext(permitCtx)
303
- ctx.args[comparison.left.index] = differentVector(ctx.args[comparison.left.index])
304
- denies.push({ dimension: 'soroswap_allowed_path', ctx })
305
- }
306
-
307
- // --- argument_reorder: swap first two address args ---
308
- // Skipped when dimensions filter is active so the synth pipeline can emit a policy;
309
- // the over-permissiveness harness then tests this mutation as a FINDING.
310
- const constrainedArgIndices = new Set<number>()
311
- for (const comparison of facts.comparisons) {
312
- if (comparison.left.kind === 'call_arg') constrainedArgIndices.add(comparison.left.index)
313
- }
314
- for (const membership of facts.memberships) {
315
- if (membership.needle.kind === 'call_arg') constrainedArgIndices.add(membership.needle.index)
316
- }
317
- const hasConstrainedArg = constrainedArgIndices.size > 0
318
- if (
319
- (!dimensions || dimensions.includes('argument_reorder')) &&
320
- hasConstrainedArg &&
321
- permitCtx.args.length >= 2 &&
322
- permitCtx.args[0]?.type === 'address' &&
323
- permitCtx.args[1]?.type === 'address' &&
324
- (permitCtx.args[0] as { type: 'address'; value: string }).value !==
325
- (permitCtx.args[1] as { type: 'address'; value: string }).value
326
- ) {
327
- const ctx = cloneContext(permitCtx)
328
- // Guard above guarantees args[0] and args[1] exist and are address-typed.
329
- const a0 = ctx.args[0] as ScVal
330
- const a1 = ctx.args[1] as ScVal
331
- ctx.args[0] = a1
332
- ctx.args[1] = a0
333
- denies.push({ dimension: 'argument_reorder', ctx })
334
- }
335
-
336
- // --- map_field_flip: flip a bound map field to a different valid value of
337
- // the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
338
- // Targets each `call_arg_field` leaf and produces a ctx whose vec element
339
- // has a different value of the recorded field (different request_type,
340
- // different amount, different address). The blend-submit case uses this
341
- // to detect a missing request_type pin. ---
342
- if (!dimensions || dimensions.includes('map_field_flip')) {
343
- for (const comparison of facts.comparisons) {
344
- const sel = comparison.left
345
- if (sel.kind !== 'call_arg_field') continue
346
- const arg = permitCtx.args[sel.index]
347
- if (arg?.type !== 'vec') continue
348
- const element = arg.value[sel.element]
349
- if (element?.type !== 'map' || !Array.isArray(element.value)) continue
350
- const entry = element.value.find((e) => e.key === sel.field)
351
- if (!entry) continue
352
- const flipped = flipFieldValue(entry.val)
353
- if (flipped === null) continue
354
- const ctx = cloneContext(permitCtx)
355
- const clonedVec = arg.value.map(cloneScVal)
356
- const clonedElement = clonedVec[sel.element]
357
- if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value)) continue
358
- clonedElement.value = clonedElement.value.map((e) =>
359
- e.key === sel.field ? { key: e.key, val: flipped } : e
360
- )
361
- clonedVec[sel.element] = clonedElement
362
- ctx.args[sel.index] = { type: 'vec', value: clonedVec }
363
- denies.push({ dimension: 'map_field_flip', ctx })
364
- }
365
- }
366
-
367
- // --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
368
- // OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
369
- // length pin a caller can append an extra element to defeat per-element binds. ---
370
- if (!dimensions || dimensions.includes('vec_append')) {
371
- for (const comparison of facts.comparisons) {
372
- const sel = comparison.left
373
- if (sel.kind !== 'call_arg_len') continue
374
- const arg = permitCtx.args[sel.index]
375
- if (arg?.type !== 'vec') continue
376
- const ctx = cloneContext(permitCtx)
377
- ctx.args[sel.index] = {
378
- type: 'vec',
379
- value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
380
- }
381
- denies.push({ dimension: 'vec_append', ctx })
382
- }
383
- }
384
-
385
- // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
386
- return { permit: cloneContext(permitCtx), denies }
387
- }
388
-
389
- function inspectPredicate(predicate: PredicateNode): PredicateFacts {
390
- const facts: PredicateFacts = { comparisons: [], memberships: [] }
391
- visit(predicate, facts)
392
- return facts
393
- }
394
-
395
- function visit(node: PredicateNode, facts: PredicateFacts): void {
396
- switch (node.op) {
397
- case 'and':
398
- case 'or':
399
- for (const child of node.children) visit(child, facts)
400
- return
401
- case 'not':
402
- visit(node.child, facts)
403
- return
404
- case 'in':
405
- facts.memberships.push(node)
406
- return
407
- case 'eq':
408
- case 'lt':
409
- case 'lte':
410
- case 'gt':
411
- case 'gte':
412
- facts.comparisons.push(node)
413
- }
414
- }
415
-
416
- function mutateBigIntRecord(
417
- permitCtx: EvalContext,
418
- recordKey: 'amountByToken' | 'windowSpentByToken',
419
- token: string,
420
- comparison: ComparisonNode,
421
- preferScaledAmount = true
422
- ): EvalContext | null {
423
- if (comparison.right.kind !== 'literal_i128') return null
424
-
425
- try {
426
- const current = BigInt(permitCtx[recordKey][token] ?? '0')
427
- const bound = BigInt(comparison.right.value)
428
- const value = violatingBigInt(comparison.op, current, bound, preferScaledAmount)
429
- const ctx = cloneContext(permitCtx)
430
- ctx[recordKey][token] = value.toString()
431
- return ctx
432
- } catch {
433
- return null
434
- }
435
- }
436
-
437
- function violatingBigInt(
438
- op: ComparisonOperator,
439
- current: bigint,
440
- bound: bigint,
441
- preferScaledAmount: boolean
442
- ): bigint {
443
- if (preferScaledAmount) {
444
- const scaledCandidates = [(current * 101n) / 100n, current * 10n]
445
- for (const candidate of scaledCandidates) {
446
- if (!bigIntComparison(op, candidate, bound)) return candidate
447
- }
448
- }
449
-
450
- return boundaryViolatingBigInt(op, bound)
451
- }
452
-
453
- /** The single value at the boundary that violates `<selector> op bound`:
454
- * bound+1 for `lte`/`eq`, bound-1 for `gte`, and bound itself for the strict
455
- * `lt`/`gt`. */
456
- function boundaryViolatingBigInt(op: ComparisonOperator, bound: bigint): bigint {
457
- switch (op) {
458
- case 'lt':
459
- case 'gt':
460
- return bound
461
- case 'lte':
462
- case 'eq':
463
- return bound + 1n
464
- case 'gte':
465
- return bound - 1n
466
- }
467
- }
468
-
469
- function violatingNumber(op: ComparisonOperator, bound: number): number {
470
- switch (op) {
471
- case 'lt':
472
- case 'gt':
473
- return bound
474
- case 'lte':
475
- case 'eq':
476
- return bound + 1
477
- case 'gte':
478
- return bound - 1
479
- }
480
- }
481
-
482
- /** Build an ScVal that VIOLATES an ordered numeric bound on a call_arg, given
483
- * the comparison op and its numeric-literal right-hand side. Returns null when
484
- * the literal is not an integer (the bound is not a numeric compare). The arg
485
- * is emitted as an i128 - the recorder's numeric args are read via BigInt, so
486
- * the wire type only needs to be a numeric ScVal to exercise the bound. */
487
- function violatingArgScVal(op: ComparisonOperator, right: PredicateLeaf): ScVal | null {
488
- const bound = literalNumericBigInt(right)
489
- if (bound === null) return null
490
- return { type: 'i128', value: boundaryViolatingBigInt(op, bound).toString() }
491
- }
492
-
493
- function bigIntComparison(op: ComparisonOperator, value: bigint, bound: bigint): boolean {
494
- switch (op) {
495
- case 'eq':
496
- return value === bound
497
- case 'lt':
498
- return value < bound
499
- case 'lte':
500
- return value <= bound
501
- case 'gt':
502
- return value > bound
503
- case 'gte':
504
- return value >= bound
505
- }
506
- }
507
-
508
- function mutateAsset(predicate: PredicateNode, permitCtx: EvalContext, token: string): EvalContext {
509
- const binding = findAddressBinding(predicate, token)
510
- const adjacent = adjacentAsset(token)
511
- const ctx = cloneContext(permitCtx)
512
- moveRecordEntry(ctx.amountByToken, token, adjacent)
513
- moveRecordEntry(ctx.windowSpentByToken, token, adjacent)
514
- if (binding.contract && ctx.contract === token) ctx.contract = adjacent
515
- for (const index of binding.argumentIndices) {
516
- const value = ctx.args[index]
517
- if (value) ctx.args[index] = replaceAddress(value, token, adjacent)
518
- }
519
- return ctx
520
- }
521
-
522
- function findAddressBinding(
523
- predicate: PredicateNode,
524
- address: string
525
- ): { contract: boolean; argumentIndices: Set<number> } {
526
- const facts = inspectPredicate(predicate)
527
- let contract = false
528
- const argumentIndices = new Set<number>()
529
-
530
- for (const comparison of facts.comparisons) {
531
- if (comparison.op !== 'eq' || !leafContainsAddress(comparison.right, address)) continue
532
- if (comparison.left.kind === 'call_contract') contract = true
533
- if (comparison.left.kind === 'call_arg') argumentIndices.add(comparison.left.index)
534
- }
535
- for (const membership of facts.memberships) {
536
- if (!membership.haystack.some((leaf) => leafContainsAddress(leaf, address))) continue
537
- if (membership.needle.kind === 'call_contract') contract = true
538
- if (membership.needle.kind === 'call_arg') argumentIndices.add(membership.needle.index)
539
- }
540
-
541
- return { contract, argumentIndices }
542
- }
543
-
544
- function leafContainsAddress(leaf: PredicateLeaf, address: string): boolean {
545
- if (leaf.kind === 'literal_address') return leaf.value === address
546
- if (leaf.kind === 'literal_vec') {
547
- return leaf.elements.some((element) => leafContainsAddress(element, address))
548
- }
549
- return false
550
- }
551
-
552
- function moveRecordEntry(record: Record<string, string>, from: string, to: string): void {
553
- const value = record[from]
554
- if (value === undefined) return
555
- delete record[from]
556
- record[to] = value
557
- }
558
-
559
- function replaceAddress(value: ScVal, from: string, to: string): ScVal {
560
- if (value.type === 'address') {
561
- return value.value === from ? { type: 'address', value: to } : { ...value }
562
- }
563
- if (value.type === 'vec') {
564
- return { type: 'vec', value: value.value.map((item) => replaceAddress(item, from, to)) }
565
- }
566
- return { ...value }
567
- }
568
-
569
- function differentVector(actual: ScVal | undefined): ScVal {
570
- if (actual?.type !== 'vec') return { type: 'vec', value: [] }
571
- if (actual.value.length === 0) {
572
- return { type: 'vec', value: [{ type: 'other', value: 'deny-case-extra-hop' }] }
573
- }
574
- if (actual.value.length > 1) {
575
- const reversed = actual.value.map(cloneScVal).reverse()
576
- if (JSON.stringify(reversed) !== JSON.stringify(actual.value)) {
577
- return { type: 'vec', value: reversed }
578
- }
579
- }
580
- const value = actual.value.map(cloneScVal)
581
- value[0] = { type: 'other', value: 'deny-case-different-hop' }
582
- return { type: 'vec', value }
583
- }
584
-
585
- function adjacentAsset(asset: string): string {
586
- return asset === ADJACENT_ASSETS[0] ? ADJACENT_ASSETS[1] : ADJACENT_ASSETS[0]
587
- }
588
-
589
- function distinctText(value: string, label: string): string {
590
- return `${value}#${label}`
591
- }
592
-
593
- function cloneContext(ctx: EvalContext): EvalContext {
594
- const cloned: EvalContext = {
595
- contract: ctx.contract,
596
- fn: ctx.fn,
597
- args: ctx.args.map(cloneScVal),
598
- atLedger: ctx.atLedger,
599
- nowSeconds: ctx.nowSeconds,
600
- amountByToken: { ...ctx.amountByToken },
601
- windowSpentByToken: { ...ctx.windowSpentByToken },
602
- invocationCountByWindow: { ...ctx.invocationCountByWindow },
603
- oraclePriceByAsset: Object.fromEntries(
604
- Object.entries(ctx.oraclePriceByAsset).map(([asset, entry]) => [
605
- asset,
606
- 'error' in entry ? { error: entry.error } : { ...entry },
607
- ])
608
- ),
609
- }
610
- if (ctx.validUntilLedger !== undefined) cloned.validUntilLedger = ctx.validUntilLedger
611
- if (ctx.signerWeights !== undefined) cloned.signerWeights = { ...ctx.signerWeights }
612
- return cloned
613
- }
614
-
615
- /** Deep-copy an ScVal so a mutation cannot alias the recorded call.
616
- * Exported so the permit-context builder shares this one implementation. */
617
- export function cloneScVal(value: ScVal, depth = 0): ScVal {
618
- // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
619
- // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
620
- // throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
621
- // (item 3) converts to a structured `{ok:false, error}`.
622
- if (value.type === 'vec') {
623
- if (depth >= MAX_SCVAL_CLONE_DEPTH) {
624
- throw cloneDepthError(value)
625
- }
626
- return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) }
627
- }
628
- return { ...value }
629
- }
630
-
631
- function cloneDepthError(value: ScVal): never {
632
- const err = new Error(
633
- `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
634
- ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
635
- err.code = 'SYNTHESIS_ERROR'
636
- err.severity = 'error'
637
- err.retryable = false
638
- err.depthContext = value.type
639
- throw err
640
- }
641
-
642
- /** Build a value of the SAME ScVal type that differs from the recorded one,
643
- * used by the `map_field_flip` mutation to defeat a per-element pin without
644
- * changing the wire type (a type/arity change is rejected by host dispatch
645
- * before Policy::enforce, so it is not the predicate's job). Returns null
646
- * when the ScVal type has no obvious different value (e.g. opaque/other). */
647
- function flipFieldValue(val: ScVal): ScVal | null {
648
- switch (val.type) {
649
- case 'address': {
650
- const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE'
651
- const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5'
652
- return { type: 'address', value: val.value === a ? b : a }
653
- }
654
- case 'i128':
655
- case 'u64':
656
- case 'u32':
657
- return { type: val.type, value: String(BigInt(val.value) + 1n) }
658
- case 'symbol':
659
- return { type: 'symbol', value: `${val.value}x` }
660
- default:
661
- return null
662
- }
663
- }