@crediolabs/policy-synth 0.1.7 → 0.1.9

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.
@@ -51,6 +51,15 @@ const ADJACENT_ASSETS = [
51
51
  const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
52
52
 
53
53
  // The 15 dimensions the synth pipeline uses for self-verify and minimise.
54
+ // Phase 1 grammar extension: `vec_append` and `map_field_flip` are listed below
55
+ // alongside the existing dimensions so the per-element binds emitted for
56
+ // Blend `submit` (call_arg_len + 3 call_arg_field per element) survive
57
+ // minimise. Without these, no deny case exercises the new leaves and the
58
+ // minimise pass drops them as "redundant" - reopening the element-append
59
+ // hole they were added to close. Both dimensions are no-ops for any fixture
60
+ // whose predicate has no `call_arg_len` / `call_arg_field` leaves (the
61
+ // generator short-circuits on leaf-kind), so they cannot make production
62
+ // synthesis refuse for fixtures that do not exercise the new grammar.
54
63
  const ORIGINAL_DIMENSIONS: string[] = [
55
64
  'amount',
56
65
  'asset',
@@ -67,6 +76,8 @@ const ORIGINAL_DIMENSIONS: string[] = [
67
76
  'oracle_deviation_exceeded',
68
77
  'oracle_paused',
69
78
  'soroswap_allowed_path',
79
+ 'vec_append',
80
+ 'map_field_flip',
70
81
  ]
71
82
 
72
83
  export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS }
@@ -263,7 +274,9 @@ export function generateCases(
263
274
  hasConstrainedArg &&
264
275
  permitCtx.args.length >= 2 &&
265
276
  permitCtx.args[0]?.type === 'address' &&
266
- permitCtx.args[1]?.type === 'address'
277
+ permitCtx.args[1]?.type === 'address' &&
278
+ (permitCtx.args[0] as { type: 'address'; value: string }).value !==
279
+ (permitCtx.args[1] as { type: 'address'; value: string }).value
267
280
  ) {
268
281
  const ctx = cloneContext(permitCtx)
269
282
  // Guard above guarantees args[0] and args[1] exist and are address-typed.
@@ -274,6 +287,55 @@ export function generateCases(
274
287
  denies.push({ dimension: 'argument_reorder', ctx })
275
288
  }
276
289
 
290
+ // --- map_field_flip: flip a bound map field to a different valid value of
291
+ // the same type. Mirrors argument_reorder: OPT-IN only, never ORIGINAL.
292
+ // Targets each `call_arg_field` leaf and produces a ctx whose vec element
293
+ // has a different value of the recorded field (different request_type,
294
+ // different amount, different address). The blend-submit case uses this
295
+ // to detect a missing request_type pin. ---
296
+ if (!dimensions || dimensions.includes('map_field_flip')) {
297
+ for (const comparison of facts.comparisons) {
298
+ const sel = comparison.left
299
+ if (sel.kind !== 'call_arg_field') continue
300
+ const arg = permitCtx.args[sel.index]
301
+ if (!arg || arg.type !== 'vec') continue
302
+ const element = arg.value[sel.element]
303
+ if (!element || element.type !== 'map' || !Array.isArray(element.value)) continue
304
+ const entry = element.value.find((e) => e.key === sel.field)
305
+ if (!entry) continue
306
+ const flipped = flipFieldValue(entry.val)
307
+ if (flipped === null) continue
308
+ const ctx = cloneContext(permitCtx)
309
+ const clonedVec = arg.value.map(cloneScVal)
310
+ const clonedElement = clonedVec[sel.element]
311
+ if (clonedElement?.type !== 'map' || !Array.isArray(clonedElement.value)) continue
312
+ clonedElement.value = clonedElement.value.map((e) =>
313
+ e.key === sel.field ? { key: e.key, val: flipped } : e
314
+ )
315
+ clonedVec[sel.element] = clonedElement
316
+ ctx.args[sel.index] = { type: 'vec', value: clonedVec }
317
+ denies.push({ dimension: 'map_field_flip', ctx })
318
+ }
319
+ }
320
+
321
+ // --- vec_append: append a new element to a bound vec. Mirrors map_field_flip:
322
+ // OPT-IN only, never ORIGINAL. Targets every `call_arg_len` leaf; without the
323
+ // length pin a caller can append an extra element to defeat per-element binds. ---
324
+ if (!dimensions || dimensions.includes('vec_append')) {
325
+ for (const comparison of facts.comparisons) {
326
+ const sel = comparison.left
327
+ if (sel.kind !== 'call_arg_len') continue
328
+ const arg = permitCtx.args[sel.index]
329
+ if (!arg || arg.type !== 'vec') continue
330
+ const ctx = cloneContext(permitCtx)
331
+ ctx.args[sel.index] = {
332
+ type: 'vec',
333
+ value: [...arg.value, { type: 'other', value: 'deny-case-vec-append' }],
334
+ }
335
+ denies.push({ dimension: 'vec_append', ctx })
336
+ }
337
+ }
338
+
277
339
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
278
340
  return { permit: cloneContext(permitCtx), denies }
279
341
  }
@@ -528,3 +590,26 @@ function cloneDepthError(value: ScVal): never {
528
590
  err.depthContext = value.type
529
591
  throw err
530
592
  }
593
+
594
+ /** Build a value of the SAME ScVal type that differs from the recorded one,
595
+ * used by the `map_field_flip` mutation to defeat a per-element pin without
596
+ * changing the wire type (a type/arity change is rejected by host dispatch
597
+ * before Policy::enforce, so it is not the predicate's job). Returns null
598
+ * when the ScVal type has no obvious different value (e.g. opaque/other). */
599
+ function flipFieldValue(val: ScVal): ScVal | null {
600
+ switch (val.type) {
601
+ case 'address': {
602
+ const a = 'GBFKRGJYZXLTDEI36ZCQEIM225NMOCR2VDBOIHJTXJ54FEFFVL2FKALE'
603
+ const b = 'GD6XSMQJ47EHHJOWXQOND5YDVZC37JWZJHYHBKE6QJFSLLJ5KQXM5QS5'
604
+ return { type: 'address', value: val.value === a ? b : a }
605
+ }
606
+ case 'i128':
607
+ case 'u64':
608
+ case 'u32':
609
+ return { type: val.type, value: String(BigInt(val.value) + 1n) }
610
+ case 'symbol':
611
+ return { type: 'symbol', value: `${val.value}x` }
612
+ default:
613
+ return null
614
+ }
615
+ }
@@ -216,6 +216,33 @@ function evalCompare(
216
216
  return evalArgEq(op, actual, right, ctx)
217
217
  }
218
218
 
219
+ // --- step 4c: call_arg_len: the length of a vec-typed argument as a u32.
220
+ // Fails closed on a non-vec arg, an absent arg, or a non-u32 literal.
221
+ if (left.kind === 'call_arg_len') {
222
+ const actual = ctx.args[left.index]
223
+ if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
224
+ if (right.kind !== 'literal_u32') return { permit: false, reason: 'ARG_MISMATCH' }
225
+ return actual.value.length === right.value
226
+ ? { permit: true }
227
+ : { permit: false, reason: 'ARG_MISMATCH' }
228
+ }
229
+
230
+ // --- step 4d: call_arg_field: the value of a field in the map at element i
231
+ // of the vec at argument index. Fails closed on a non-vec arg, an
232
+ // out-of-range element, a missing field, a non-map element, or a type
233
+ // mismatch between the field ScVal and the literal leaf.
234
+ if (left.kind === 'call_arg_field') {
235
+ const actual = ctx.args[left.index]
236
+ if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
237
+ const element = actual.value[left.element]
238
+ if (!element || element.type !== 'map') return { permit: false, reason: 'ARG_MISMATCH' }
239
+ if (!Array.isArray(element.value)) return { permit: false, reason: 'ARG_MISMATCH' }
240
+ const entry = element.value.find((e) => e.key === left.field)
241
+ if (!entry) return { permit: false, reason: 'ARG_MISMATCH' }
242
+ if (op === 'eq') return evalArgEq(op, entry.val, right, ctx)
243
+ return evalArgOrderedCompare(op, entry.val, right)
244
+ }
245
+
219
246
  // --- step 6: AMOUNT_BOUND ---
220
247
  if (left.kind === 'amount' && op !== 'eq') {
221
248
  return evalAmountCompare(op, left.token, right, ctx)
@@ -383,6 +410,20 @@ function resolveLeaf(leaf: PredicateLeaf, ctx: EvalContext): ScVal | undefined {
383
410
  return { type: 'symbol', value: ctx.fn }
384
411
  case 'call_arg':
385
412
  return ctx.args[leaf.index]
413
+ case 'call_arg_len':
414
+ // No direct ScVal projection: the length is an integer the comparator
415
+ // resolves against the right-hand literal. Returning undefined keeps
416
+ // the `in` membership path structurally informed (no haystack match).
417
+ return undefined
418
+ case 'call_arg_field': {
419
+ const actual = ctx.args[leaf.index]
420
+ if (!actual || actual.type !== 'vec') return undefined
421
+ const element = actual.value[leaf.element]
422
+ if (!element || element.type !== 'map') return undefined
423
+ if (!Array.isArray(element.value)) return undefined
424
+ const entry = element.value.find((e) => e.key === leaf.field)
425
+ return entry ? entry.val : undefined
426
+ }
386
427
  case 'amount':
387
428
  case 'window_spent':
388
429
  case 'oracle_price':
package/src/types.ts CHANGED
@@ -145,6 +145,16 @@ export type PredicateLeaf =
145
145
  | { kind: 'call_contract' }
146
146
  | { kind: 'call_fn' }
147
147
  | { kind: 'call_arg'; index: number }
148
+ // Length of a vec-typed argument as a u32. Binds the OUTER vec length so
149
+ // a caller cannot append a new element to defeat a per-element pin.
150
+ // Paired with `call_arg_field` per element - pinning only the field leaves
151
+ // an element-append vulnerability; this leaf closes it.
152
+ | { kind: 'call_arg_len'; index: number }
153
+ // Value of `field` in the map at `element` of the vec at argument `index`.
154
+ // Fixed-arity: index, element, field. The leaf is resolved against the
155
+ // recorded ScVal type. A non-vec arg, missing element, missing field, or
156
+ // type mismatch all DENY (fail-closed).
157
+ | { kind: 'call_arg_field'; index: number; element: number; field: string }
148
158
  // `call_sub_invocation[i]` is NOT in v1 grammar. OZ `Policy::enforce`
149
159
  // receives one `Context`, not a sub-invocation tree; sub-invocations live under
150
160
  // `InvokerContractAuthEntry::Contract(SubContractInvocation)`, not in the