@crediolabs/policy-synth 0.1.8 → 0.1.10
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/dist/adapters/interpreter/adapter.js +21 -3
- package/dist/adapters/oz/adapter.js +8 -0
- package/dist/ir/types.d.ts +9 -0
- package/dist/predicate/encode.js +9 -0
- package/dist/review-card/builder.js +32 -0
- package/dist/review-card/cross-check.js +40 -0
- package/dist/run/index.d.ts +8 -2
- package/dist/run/index.js +2 -1
- package/dist/run/schemas.d.ts +12 -0
- package/dist/run/schemas.js +14 -0
- package/dist/synth/compose-from-recording.js +89 -0
- package/dist/synth/deny-cases.js +89 -1
- package/dist/synth/evaluate.js +49 -0
- package/dist/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist/synth/synthesize-from-mandate.js +27 -2
- package/dist/synth/synthesize-from-recording.d.ts +25 -1
- package/dist/synth/synthesize-from-recording.js +66 -1
- package/dist/types.d.ts +8 -0
- package/dist-cjs/adapters/interpreter/adapter.js +21 -3
- package/dist-cjs/adapters/oz/adapter.js +8 -0
- package/dist-cjs/ir/types.d.ts +9 -0
- package/dist-cjs/predicate/encode.js +9 -0
- package/dist-cjs/review-card/builder.js +32 -0
- package/dist-cjs/review-card/cross-check.js +40 -0
- package/dist-cjs/run/index.d.ts +8 -2
- package/dist-cjs/run/index.js +2 -1
- package/dist-cjs/run/schemas.d.ts +12 -0
- package/dist-cjs/run/schemas.js +14 -0
- package/dist-cjs/synth/compose-from-recording.js +89 -0
- package/dist-cjs/synth/deny-cases.js +89 -1
- package/dist-cjs/synth/evaluate.js +49 -0
- package/dist-cjs/synth/synthesize-from-mandate.d.ts +17 -2
- package/dist-cjs/synth/synthesize-from-mandate.js +27 -2
- package/dist-cjs/synth/synthesize-from-recording.d.ts +25 -1
- package/dist-cjs/synth/synthesize-from-recording.js +66 -1
- package/dist-cjs/types.d.ts +8 -0
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +20 -4
- package/src/adapters/oz/adapter.ts +8 -0
- package/src/ir/types.ts +8 -0
- package/src/predicate/encode.ts +9 -0
- package/src/review-card/builder.ts +28 -0
- package/src/review-card/cross-check.ts +40 -0
- package/src/run/index.ts +16 -2
- package/src/run/schemas.ts +14 -0
- package/src/synth/compose-from-recording.ts +87 -0
- package/src/synth/deny-cases.ts +86 -1
- package/src/synth/evaluate.ts +41 -0
- package/src/synth/synthesize-from-mandate.ts +40 -4
- package/src/synth/synthesize-from-recording.ts +103 -4
- package/src/types.ts +10 -0
|
@@ -171,6 +171,32 @@ function renderComparison(
|
|
|
171
171
|
return `Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// eq(call_arg_len[i], literal_u32) -> Length of arg[i] is K
|
|
175
|
+
// Binds the OUTER vec length so a caller cannot append an element to
|
|
176
|
+
// defeat a per-element pin. Rendered as a single readable line so the
|
|
177
|
+
// review card surfaces the implicit "no new elements" guarantee.
|
|
178
|
+
if (left.kind === 'call_arg_len' && node.op === 'eq' && right.kind === 'literal_u32') {
|
|
179
|
+
return `Length of arg[${left.index}] is ${right.value}`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// eq/lte(call_arg_field[i, el, field], <literal>) -> arg[i] element[el].field = <value>
|
|
183
|
+
// The structured-argument bind pins a scalar field inside a vec element.
|
|
184
|
+
// For lt/gt/lte/gte the line reads "OP <value>" so the comparison kind
|
|
185
|
+
// is visible; eq reads "= <value>".
|
|
186
|
+
if (left.kind === 'call_arg_field') {
|
|
187
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`
|
|
188
|
+
const sep = node.op === 'eq' ? '=' : comparisonOpText(node.op)
|
|
189
|
+
if (right.kind === 'literal_address') return `${head} ${sep} ${right.value}`
|
|
190
|
+
if (right.kind === 'literal_symbol') return `${head} ${sep} ${right.value}`
|
|
191
|
+
if (right.kind === 'literal_bytes') return `${head} ${sep} ${right.value}`
|
|
192
|
+
if (right.kind === 'literal_u64') return `${head} ${sep} ${right.value}`
|
|
193
|
+
if (right.kind === 'literal_i128') return `${head} ${sep} ${right.value}`
|
|
194
|
+
if (right.kind === 'literal_u32') return `${head} ${sep} ${right.value}`
|
|
195
|
+
if (right.kind === 'literal_vec') {
|
|
196
|
+
return `${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
// invocation_count_in_window <= N -> Invocations <= N per <window> seconds
|
|
175
201
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
176
202
|
return `Invocations <= ${right.value} per ${left.windowSecs} seconds`
|
|
@@ -222,6 +248,8 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
222
248
|
case 'call_contract':
|
|
223
249
|
case 'call_fn':
|
|
224
250
|
case 'call_arg':
|
|
251
|
+
case 'call_arg_len':
|
|
252
|
+
case 'call_arg_field':
|
|
225
253
|
case 'amount':
|
|
226
254
|
case 'window_spent':
|
|
227
255
|
case 'now':
|
|
@@ -80,6 +80,44 @@ function pushComparison(
|
|
|
80
80
|
out.push(`Path must be exactly [${right.elements.map(renderVecElement).join(', ')}]`)
|
|
81
81
|
return
|
|
82
82
|
}
|
|
83
|
+
if (left.kind === 'call_arg_len' && op === 'eq' && right.kind === 'literal_u32') {
|
|
84
|
+
out.push(`Length of arg[${left.index}] is ${right.value}`)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// call_arg_field: must mirror the builder's templates byte-for-byte so
|
|
88
|
+
// the cross-check can assert a faithful summary.
|
|
89
|
+
if (left.kind === 'call_arg_field') {
|
|
90
|
+
const head = `arg[${left.index}] element[${left.element}].${left.field}`
|
|
91
|
+
const sep = op === 'eq' ? '=' : comparisonOpText(op)
|
|
92
|
+
if (right.kind === 'literal_address') {
|
|
93
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (right.kind === 'literal_symbol') {
|
|
97
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
if (right.kind === 'literal_bytes') {
|
|
101
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (right.kind === 'literal_u64') {
|
|
105
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (right.kind === 'literal_i128') {
|
|
109
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (right.kind === 'literal_u32') {
|
|
113
|
+
out.push(`${head} ${sep} ${right.value}`)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (right.kind === 'literal_vec') {
|
|
117
|
+
out.push(`${head} ${sep} [${right.elements.map(renderVecElement).join(', ')}]`)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
}
|
|
83
121
|
if (left.kind === 'invocation_count_in_window' && right.kind === 'literal_u32') {
|
|
84
122
|
out.push(`Invocations <= ${right.value} per ${left.windowSecs} seconds`)
|
|
85
123
|
return
|
|
@@ -119,6 +157,8 @@ function renderVecElement(leaf: PredicateLeaf): string {
|
|
|
119
157
|
case 'call_contract':
|
|
120
158
|
case 'call_fn':
|
|
121
159
|
case 'call_arg':
|
|
160
|
+
case 'call_arg_len':
|
|
161
|
+
case 'call_arg_field':
|
|
122
162
|
case 'amount':
|
|
123
163
|
case 'window_spent':
|
|
124
164
|
case 'now':
|
package/src/run/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type ErrorCode,
|
|
22
22
|
type MandateSpec,
|
|
23
23
|
type OzAdapterConfig,
|
|
24
|
+
type PredicateNode,
|
|
24
25
|
type ProposedPolicy,
|
|
25
26
|
placeholderOzConfig,
|
|
26
27
|
type RecordedTransaction,
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type ToolError,
|
|
32
33
|
type ToolResponse,
|
|
33
34
|
} from '../index.ts'
|
|
35
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
34
36
|
import {
|
|
35
37
|
type RecordTransactionInput,
|
|
36
38
|
RecordTransactionInputSchema,
|
|
@@ -112,7 +114,14 @@ export async function runRecordTransaction(
|
|
|
112
114
|
* `{ok:false, error}`, but a raw throw from the SDK (e.g. an unexpected
|
|
113
115
|
* XDR decode error in the adapter) would otherwise surface as
|
|
114
116
|
* "[object Object]" in the MCP transport. */
|
|
115
|
-
export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
117
|
+
export async function runSynthesizePolicy(raw: unknown): Promise<
|
|
118
|
+
ToolResponse<ProposedPolicy> & {
|
|
119
|
+
explain?: {
|
|
120
|
+
predicateTree: PredicateNode | null
|
|
121
|
+
simulation: SimulationResult
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
> {
|
|
116
125
|
const parsed = SynthesizePolicyInputSchema.safeParse(raw)
|
|
117
126
|
if (!parsed.success) {
|
|
118
127
|
return {
|
|
@@ -128,7 +137,11 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
|
|
|
128
137
|
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
129
138
|
// exact-optional MandateSpec rejects; the schema already validated the
|
|
130
139
|
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
131
|
-
return await synthesizeFromMandate(
|
|
140
|
+
return await synthesizeFromMandate(
|
|
141
|
+
input.mandate as MandateSpec,
|
|
142
|
+
ozConfig,
|
|
143
|
+
input.explain === true ? { explain: true } : {}
|
|
144
|
+
)
|
|
132
145
|
}
|
|
133
146
|
// recording source
|
|
134
147
|
const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
|
|
@@ -141,6 +154,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<Pr
|
|
|
141
154
|
? { confidenceOverride: input.confidenceOverride }
|
|
142
155
|
: {}),
|
|
143
156
|
...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
|
|
157
|
+
...(input.explain === true ? { explain: true } : {}),
|
|
144
158
|
} as SynthesizeFromRecordingOptions,
|
|
145
159
|
ozConfig
|
|
146
160
|
)
|
package/src/run/schemas.ts
CHANGED
|
@@ -197,6 +197,11 @@ export const SynthesizePolicyMandateInputSchema = z.object({
|
|
|
197
197
|
source: z.literal('mandate'),
|
|
198
198
|
mandate: MandateSpecSchema,
|
|
199
199
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
200
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
201
|
+
// in-memory predicate tree (null for the mandate path) + a minimal
|
|
202
|
+
// honest SimulationResult to the success envelope. Absent or false
|
|
203
|
+
// -> the success envelope is unchanged (byte-identical to today).
|
|
204
|
+
explain: z.boolean().optional(),
|
|
200
205
|
})
|
|
201
206
|
|
|
202
207
|
/** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
|
|
@@ -224,6 +229,15 @@ export const SynthesizePolicyRecordingInputSchema = z.object({
|
|
|
224
229
|
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
225
230
|
interpreter: InterpreterOptionsSchema.optional(),
|
|
226
231
|
ozConfig: OzAdapterConfigSchema.optional(),
|
|
232
|
+
// --explain opt-in. When true, the orchestrator attaches the
|
|
233
|
+
// in-memory PredicateNode + the corresponding SimulationResult
|
|
234
|
+
// (real one from the self-verify pipeline when the interpreter is
|
|
235
|
+
// engaged, minimal honest value otherwise) to the success envelope.
|
|
236
|
+
// Absent or false -> the success envelope is unchanged (byte-identical
|
|
237
|
+
// to today). The flag is ADDITIVE: the existing ProposedPolicy fields
|
|
238
|
+
// (encodedPredicate, predicateHash, etc.) are never altered by enabling
|
|
239
|
+
// explain.
|
|
240
|
+
explain: z.boolean().optional(),
|
|
227
241
|
})
|
|
228
242
|
|
|
229
243
|
export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
@@ -381,6 +381,93 @@ function appendProtocolSpecificConstraints(
|
|
|
381
381
|
}
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
// Blend `submit` ONLY (not `claim`, whose vec arg is a vec<u32> of
|
|
385
|
+
// reserve_token_ids - no map fields to bind). The `requests` vec
|
|
386
|
+
// (call_arg[3]) is a vec<Request{ address, amount, request_type }>. Each
|
|
387
|
+
// Request is the per-reserve action selector - 0 Supply, 1 Withdraw,
|
|
388
|
+
// 2 SupplyCollateral, 3 WithdrawCollateral, 4 Borrow, 5 Repay, 6-9
|
|
389
|
+
// liquidation/auction fills. Pinning only one element is unsafe: a caller
|
|
390
|
+
// can append a second element with a different action (WithdrawCollateral
|
|
391
|
+
// -> Borrow on a different asset, any amount, then auction fills). Length
|
|
392
|
+
// + per-element pinning is total; a quantifier over elements is not. If we
|
|
393
|
+
// cannot emit BOTH, we surface AMBIGUITY rather than emit a partial bind.
|
|
394
|
+
if (protocol.protocol === 'blend' && protocol.fn === 'submit' && interpreterEnabled) {
|
|
395
|
+
const requestsArg = topLevel.args[3]
|
|
396
|
+
if (requestsArg && requestsArg.type === 'vec') {
|
|
397
|
+
const elements = requestsArg.value
|
|
398
|
+
interpreterConstraints.push({
|
|
399
|
+
op: 'compare',
|
|
400
|
+
compare: {
|
|
401
|
+
selector: { kind: 'arg_len', argIndex: 3 },
|
|
402
|
+
operator: 'eq',
|
|
403
|
+
value: String(elements.length),
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
for (let i = 0; i < elements.length; i++) {
|
|
407
|
+
const element = elements[i]
|
|
408
|
+
if (!element || element.type !== 'map') continue
|
|
409
|
+
const fields = element.value
|
|
410
|
+
const fieldValue = (
|
|
411
|
+
name: string,
|
|
412
|
+
scalarType: 'address' | 'i128' | 'u32'
|
|
413
|
+
): string | null => {
|
|
414
|
+
const entry = fields.find((e) => e.key === name)
|
|
415
|
+
if (!entry) return null
|
|
416
|
+
if (entry.val.type === 'address' && scalarType === 'address') return entry.val.value
|
|
417
|
+
if (entry.val.type === 'i128' && scalarType === 'i128') return entry.val.value
|
|
418
|
+
if (entry.val.type === 'u32' && scalarType === 'u32') return entry.val.value
|
|
419
|
+
return null
|
|
420
|
+
}
|
|
421
|
+
const address = fieldValue('address', 'address')
|
|
422
|
+
const amount = fieldValue('amount', 'i128')
|
|
423
|
+
const requestType = fieldValue('request_type', 'u32')
|
|
424
|
+
if (address === null || amount === null || requestType === null) continue
|
|
425
|
+
interpreterConstraints.push({
|
|
426
|
+
op: 'compare',
|
|
427
|
+
compare: {
|
|
428
|
+
selector: {
|
|
429
|
+
kind: 'arg_field',
|
|
430
|
+
argIndex: 3,
|
|
431
|
+
element: i,
|
|
432
|
+
field: 'request_type',
|
|
433
|
+
scalarType: 'u32',
|
|
434
|
+
},
|
|
435
|
+
operator: 'eq',
|
|
436
|
+
value: requestType,
|
|
437
|
+
},
|
|
438
|
+
})
|
|
439
|
+
interpreterConstraints.push({
|
|
440
|
+
op: 'compare',
|
|
441
|
+
compare: {
|
|
442
|
+
selector: {
|
|
443
|
+
kind: 'arg_field',
|
|
444
|
+
argIndex: 3,
|
|
445
|
+
element: i,
|
|
446
|
+
field: 'address',
|
|
447
|
+
scalarType: 'address',
|
|
448
|
+
},
|
|
449
|
+
operator: 'eq',
|
|
450
|
+
value: address,
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
interpreterConstraints.push({
|
|
454
|
+
op: 'compare',
|
|
455
|
+
compare: {
|
|
456
|
+
selector: {
|
|
457
|
+
kind: 'arg_field',
|
|
458
|
+
argIndex: 3,
|
|
459
|
+
element: i,
|
|
460
|
+
field: 'amount',
|
|
461
|
+
scalarType: 'i128',
|
|
462
|
+
},
|
|
463
|
+
operator: 'lte',
|
|
464
|
+
value: amount,
|
|
465
|
+
},
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
384
471
|
// SoroSwap: the recorded hop path -> eq_seq on the path arg (call_arg[2]).
|
|
385
472
|
// The interpreter adapter is the ONLY way to express an exact ordered
|
|
386
473
|
// sequence (OZ built-ins cannot). Element order is preserved verbatim.
|
package/src/synth/deny-cases.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/synth/evaluate.ts
CHANGED
|
@@ -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':
|
|
@@ -13,14 +13,27 @@ import { createOzAdapter } from '../adapters/oz/adapter.ts'
|
|
|
13
13
|
import type { ToolResponse } from '../errors.ts'
|
|
14
14
|
import { mandateToPolicyIR } from '../mandate/to-ir.ts'
|
|
15
15
|
import type { MandateSpec } from '../mandate/types.ts'
|
|
16
|
-
import type { ProposedPolicy } from '../types.ts'
|
|
16
|
+
import type { PredicateNode, ProposedPolicy } from '../types.ts'
|
|
17
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
17
18
|
|
|
18
19
|
const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
|
|
19
20
|
|
|
20
21
|
export function synthesizeFromMandate(
|
|
21
22
|
spec: MandateSpec,
|
|
22
|
-
ozConfig: OzAdapterConfig
|
|
23
|
-
|
|
23
|
+
ozConfig: OzAdapterConfig,
|
|
24
|
+
/** --explain opt-in. When true, the success envelope carries the
|
|
25
|
+
* in-memory predicate tree (always null for the mandate path - the
|
|
26
|
+
* declarative MandateSpec lowers to OZ built-ins, not to an
|
|
27
|
+
* interpreter predicate) + a minimal honest SimulationResult. The
|
|
28
|
+
* flag is ADDITIVE: the existing ProposedPolicy fields are never
|
|
29
|
+
* altered. */
|
|
30
|
+
opts?: { explain?: true }
|
|
31
|
+
): ToolResponse<ProposedPolicy> & {
|
|
32
|
+
explain?: {
|
|
33
|
+
predicateTree: PredicateNode | null
|
|
34
|
+
simulation: SimulationResult
|
|
35
|
+
}
|
|
36
|
+
} {
|
|
24
37
|
const ir = mandateToPolicyIR(spec)
|
|
25
38
|
const adapter = createOzAdapter(ozConfig)
|
|
26
39
|
const result = adapter.compile(ir)
|
|
@@ -42,5 +55,28 @@ export function synthesizeFromMandate(
|
|
|
42
55
|
...result.proposed,
|
|
43
56
|
warnings: result.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
|
|
44
57
|
}
|
|
45
|
-
|
|
58
|
+
// Same --explain envelope pattern as the recording path. The mandate path
|
|
59
|
+
// never produces an interpreter predicate, so predicateTree is null and
|
|
60
|
+
// the simulation is a minimal honest deny (no self-verify was performed).
|
|
61
|
+
const envelope: ToolResponse<ProposedPolicy> & {
|
|
62
|
+
explain?: {
|
|
63
|
+
predicateTree: PredicateNode | null
|
|
64
|
+
simulation: SimulationResult
|
|
65
|
+
}
|
|
66
|
+
} = { ok: true, data: proposed }
|
|
67
|
+
if (opts?.explain) {
|
|
68
|
+
envelope.explain = {
|
|
69
|
+
predicateTree: null,
|
|
70
|
+
simulation: {
|
|
71
|
+
permit: {
|
|
72
|
+
tx: 'deny',
|
|
73
|
+
reason: 'No self-verification was performed (mandate path is OZ-only)',
|
|
74
|
+
},
|
|
75
|
+
evaluatedCases: [],
|
|
76
|
+
backend: 'ts-model',
|
|
77
|
+
simulatorVersion: 'not-run',
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return envelope
|
|
46
82
|
}
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
type RecordedTransaction,
|
|
53
53
|
SOROBAN_LIMITS,
|
|
54
54
|
} from '../types.ts'
|
|
55
|
+
import type { SimulationResult } from '../verify/envelope.ts'
|
|
55
56
|
import {
|
|
56
57
|
type ComposeOptions,
|
|
57
58
|
type ComposeUserResponses,
|
|
@@ -101,13 +102,36 @@ export interface SynthesizeFromRecordingOptions {
|
|
|
101
102
|
userResponses?: ComposeUserResponses
|
|
102
103
|
confidenceOverride?: { threshold: number }
|
|
103
104
|
interpreter?: InterpreterAdapterOptions
|
|
105
|
+
/** --explain opt-in. When true, the orchestrator attaches the
|
|
106
|
+
* in-memory `PredicateNode` + the corresponding `SimulationResult`
|
|
107
|
+
* to the success envelope so the CLI can render a human-readable
|
|
108
|
+
* review card. Absent or false -> the success envelope is unchanged
|
|
109
|
+
* (byte-identical to today). The flag is ADDITIVE: the existing
|
|
110
|
+
* ProposedPolicy fields (encodedPredicate, predicateHash, etc.) are
|
|
111
|
+
* never altered by enabling explain. */
|
|
112
|
+
explain?: true
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
export function synthesizeFromRecording(
|
|
107
116
|
tx: RecordedTransaction,
|
|
108
117
|
opts: SynthesizeFromRecordingOptions,
|
|
109
118
|
ozConfig: OzAdapterConfig
|
|
110
|
-
): ToolResponse<ProposedPolicy> {
|
|
119
|
+
): ToolResponse<ProposedPolicy> & {
|
|
120
|
+
/** Present iff `opts.explain === true` and the synthesis succeeded. The
|
|
121
|
+
* `predicateTree` is the exact in-memory `PredicateNode` (canonical
|
|
122
|
+
* JSON shape) that was encoded into `proposed.policyDocuments[*].encodedPredicate`
|
|
123
|
+
* - reading the predicate tree from the policy document is therefore
|
|
124
|
+
* unnecessary; the orchestrator carries it through. The `simulation`
|
|
125
|
+
* is the verdict produced by the same self-verify pipeline that already
|
|
126
|
+
* runs in the recording path (runHarness + evaluate), so the value is
|
|
127
|
+
* REAL not synthetic. Absent when the synthesis did not engage the
|
|
128
|
+
* interpreter (no interpreter opts supplied) - in that case the CLI
|
|
129
|
+
* builds a minimal honest SimulationResult downstream. */
|
|
130
|
+
explain?: {
|
|
131
|
+
predicateTree: PredicateNode | null
|
|
132
|
+
simulation: SimulationResult
|
|
133
|
+
}
|
|
134
|
+
} {
|
|
111
135
|
// Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
|
|
112
136
|
// with a string `.code`) inside the body is converted to a structured
|
|
113
137
|
// `{ok:false, error}`; anything else is rethrown so genuine bugs crash
|
|
@@ -159,7 +183,12 @@ function synthesizeFromRecordingInner(
|
|
|
159
183
|
tx: RecordedTransaction,
|
|
160
184
|
opts: SynthesizeFromRecordingOptions,
|
|
161
185
|
ozConfig: OzAdapterConfig
|
|
162
|
-
): ToolResponse<ProposedPolicy> {
|
|
186
|
+
): ToolResponse<ProposedPolicy> & {
|
|
187
|
+
explain?: {
|
|
188
|
+
predicateTree: PredicateNode | null
|
|
189
|
+
simulation: SimulationResult
|
|
190
|
+
}
|
|
191
|
+
} {
|
|
163
192
|
// 0. validate inputs (fail closed - never synthesize from garbage).
|
|
164
193
|
const invalid = validateOptions(opts)
|
|
165
194
|
if (invalid) return { ok: false, error: invalid }
|
|
@@ -272,6 +301,15 @@ function synthesizeFromRecordingInner(
|
|
|
272
301
|
}
|
|
273
302
|
const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
|
|
274
303
|
|
|
304
|
+
// --explain hook: capture the in-memory predicate tree + the real
|
|
305
|
+
// self-verify verdict so the CLI can render a faithful review card.
|
|
306
|
+
// The verdict below is built from the SAME runHarness + evaluate that
|
|
307
|
+
// already gates the synthesis (it is not a parallel simulation); the
|
|
308
|
+
// intermediate inputs (harnessCases, evalResult) are otherwise discarded
|
|
309
|
+
// after the gate, so the explain hook reuses them - no extra work.
|
|
310
|
+
let explain: PredicateNode | null = null
|
|
311
|
+
let explainSim: SimulationResult | null = null
|
|
312
|
+
|
|
275
313
|
// 5. OZ compile (always runs).
|
|
276
314
|
const ozAdapter = createOzAdapter(ozConfig)
|
|
277
315
|
const compileRes = ozAdapter.compile(composed.ir)
|
|
@@ -449,7 +487,6 @@ function synthesizeFromRecordingInner(
|
|
|
449
487
|
},
|
|
450
488
|
}
|
|
451
489
|
}
|
|
452
|
-
|
|
453
490
|
const evalResult = evaluate(finalPredicate, permitCtx)
|
|
454
491
|
if (!evalResult.permit) {
|
|
455
492
|
return {
|
|
@@ -464,6 +501,37 @@ function synthesizeFromRecordingInner(
|
|
|
464
501
|
}
|
|
465
502
|
}
|
|
466
503
|
|
|
504
|
+
// --explain capture: the interpreter path already produced the real
|
|
505
|
+
// self-verify verdict (runHarness passed, evalResult.permit is true).
|
|
506
|
+
// Build the SimulationResult from those outputs so the CLI card
|
|
507
|
+
// quotes the SAME verdict that gated the synthesis. We re-evaluate
|
|
508
|
+
// each deny case to surface its concrete reason; the harness only
|
|
509
|
+
// records whether the got-matches-expected boundary held.
|
|
510
|
+
if (opts.explain) {
|
|
511
|
+
explain = finalPredicate
|
|
512
|
+
const evaluatedCases: SimulationResult['evaluatedCases'] = [
|
|
513
|
+
{
|
|
514
|
+
dimension: 'permit',
|
|
515
|
+
outcome: evalResult.permit ? 'permit' : 'deny',
|
|
516
|
+
reason: 'matches recorded call',
|
|
517
|
+
},
|
|
518
|
+
]
|
|
519
|
+
for (const deny of harnessCases.denies) {
|
|
520
|
+
const r = evaluate(finalPredicate, deny.ctx)
|
|
521
|
+
evaluatedCases.push({
|
|
522
|
+
dimension: deny.dimension,
|
|
523
|
+
outcome: r.permit ? 'permit' : 'deny',
|
|
524
|
+
reason: r.permit ? 'no matching deny' : r.reason,
|
|
525
|
+
})
|
|
526
|
+
}
|
|
527
|
+
explainSim = {
|
|
528
|
+
permit: { tx: 'permit' },
|
|
529
|
+
evaluatedCases,
|
|
530
|
+
backend: 'ts-model',
|
|
531
|
+
simulatorVersion: 'ts-model-1.0.0',
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
467
535
|
// 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
|
|
468
536
|
// canonical bytes back onto the PolicyDocument + PolicyRef. The
|
|
469
537
|
// `encodePredicate` helper throws ToolError-shaped errors on cap
|
|
@@ -566,7 +634,38 @@ function synthesizeFromRecordingInner(
|
|
|
566
634
|
],
|
|
567
635
|
ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
|
|
568
636
|
}
|
|
569
|
-
|
|
637
|
+
// --explain success envelope. The interpreter path populated
|
|
638
|
+
// `explain` + `explainSim` from the real self-verify verdict above;
|
|
639
|
+
// the OZ-only path did not (no predicate tree exists). When opts.explain
|
|
640
|
+
// is set and the OZ-only path ran, construct the minimal honest
|
|
641
|
+
// SimulationResult: the verdict is NOT a passing simulation - the
|
|
642
|
+
// interpreter was never engaged, so permit is deny with a truthful
|
|
643
|
+
// reason and evaluatedCases is empty.
|
|
644
|
+
const envelope: ToolResponse<ProposedPolicy> & {
|
|
645
|
+
explain?: {
|
|
646
|
+
predicateTree: PredicateNode | null
|
|
647
|
+
simulation: SimulationResult
|
|
648
|
+
}
|
|
649
|
+
} = { ok: true, data: proposed }
|
|
650
|
+
if (opts.explain) {
|
|
651
|
+
if (explainSim) {
|
|
652
|
+
envelope.explain = { predicateTree: explain, simulation: explainSim }
|
|
653
|
+
} else {
|
|
654
|
+
envelope.explain = {
|
|
655
|
+
predicateTree: null,
|
|
656
|
+
simulation: {
|
|
657
|
+
permit: {
|
|
658
|
+
tx: 'deny',
|
|
659
|
+
reason: 'No self-verification was performed (interpreter adapter was not engaged)',
|
|
660
|
+
},
|
|
661
|
+
evaluatedCases: [],
|
|
662
|
+
backend: 'ts-model',
|
|
663
|
+
simulatorVersion: 'not-run',
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return envelope
|
|
570
669
|
}
|
|
571
670
|
|
|
572
671
|
// Re-export the throwToolError helper for callers that need to surface a
|