@crediolabs/policy-synth 0.1.2 → 0.1.4

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 (92) hide show
  1. package/README.md +193 -5
  2. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  3. package/dist/adapters/interpreter/adapter.js +522 -0
  4. package/dist/adapters/interpreter/index.d.ts +1 -0
  5. package/dist/adapters/interpreter/index.js +2 -0
  6. package/dist/adapters/oz/adapter.js +20 -18
  7. package/dist/codegen/compile-gate.d.ts +33 -0
  8. package/dist/codegen/compile-gate.js +119 -0
  9. package/dist/codegen/index.d.ts +2 -0
  10. package/dist/codegen/index.js +8 -0
  11. package/dist/codegen/template.d.ts +18 -0
  12. package/dist/codegen/template.js +131 -0
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/ir/types.d.ts +13 -2
  17. package/dist/mandate/to-ir.js +4 -4
  18. package/dist/mandate/types.d.ts +1 -1
  19. package/dist/predicate/encode.d.ts +10 -0
  20. package/dist/predicate/encode.js +249 -0
  21. package/dist/predicate/index.d.ts +1 -0
  22. package/dist/predicate/index.js +2 -0
  23. package/dist/review-card/builder.d.ts +14 -0
  24. package/dist/review-card/builder.js +261 -0
  25. package/dist/review-card/conflict.d.ts +40 -0
  26. package/dist/review-card/conflict.js +111 -0
  27. package/dist/review-card/cross-check.d.ts +11 -0
  28. package/dist/review-card/cross-check.js +148 -0
  29. package/dist/review-card/index.d.ts +3 -0
  30. package/dist/review-card/index.js +4 -0
  31. package/dist/seams/types.d.ts +1 -1
  32. package/dist/synth/compose-from-recording.d.ts +35 -7
  33. package/dist/synth/compose-from-recording.js +231 -40
  34. package/dist/synth/deny-cases.d.ts +12 -0
  35. package/dist/synth/deny-cases.js +361 -0
  36. package/dist/synth/evaluate.d.ts +39 -0
  37. package/dist/synth/evaluate.js +425 -0
  38. package/dist/synth/harness.d.ts +16 -0
  39. package/dist/synth/harness.js +26 -0
  40. package/dist/synth/index.d.ts +4 -0
  41. package/dist/synth/index.js +4 -0
  42. package/dist/synth/lower.d.ts +2 -2
  43. package/dist/synth/minimize.d.ts +4 -0
  44. package/dist/synth/minimize.js +38 -0
  45. package/dist/synth/predicate-literals.d.ts +5 -0
  46. package/dist/synth/predicate-literals.js +25 -0
  47. package/dist/synth/synthesize-from-mandate.js +5 -5
  48. package/dist/synth/synthesize-from-recording.d.ts +32 -3
  49. package/dist/synth/synthesize-from-recording.js +491 -17
  50. package/dist/types.d.ts +23 -1
  51. package/dist/verify/envelope.d.ts +15 -0
  52. package/dist/verify/envelope.js +22 -0
  53. package/dist/verify/index.d.ts +3 -0
  54. package/dist/verify/index.js +3 -0
  55. package/dist/verify/simulate.d.ts +31 -0
  56. package/dist/verify/simulate.js +242 -0
  57. package/dist/verify/verify.d.ts +21 -0
  58. package/dist/verify/verify.js +173 -0
  59. package/package.json +1 -1
  60. package/src/adapters/interpreter/adapter.ts +642 -0
  61. package/src/adapters/interpreter/index.ts +8 -0
  62. package/src/adapters/oz/adapter.ts +20 -18
  63. package/src/codegen/compile-gate.ts +162 -0
  64. package/src/codegen/index.ts +17 -0
  65. package/src/codegen/template.ts +148 -0
  66. package/src/errors.ts +2 -0
  67. package/src/index.ts +2 -0
  68. package/src/ir/types.ts +9 -2
  69. package/src/mandate/to-ir.ts +4 -4
  70. package/src/mandate/types.ts +1 -1
  71. package/src/predicate/encode.ts +307 -0
  72. package/src/predicate/index.ts +3 -0
  73. package/src/review-card/builder.ts +303 -0
  74. package/src/review-card/conflict.ts +143 -0
  75. package/src/review-card/cross-check.ts +158 -0
  76. package/src/review-card/index.ts +12 -0
  77. package/src/seams/types.ts +1 -1
  78. package/src/synth/compose-from-recording.ts +283 -49
  79. package/src/synth/deny-cases.ts +447 -0
  80. package/src/synth/evaluate.ts +493 -0
  81. package/src/synth/harness.ts +40 -0
  82. package/src/synth/index.ts +12 -0
  83. package/src/synth/lower.ts +2 -2
  84. package/src/synth/minimize.ts +44 -0
  85. package/src/synth/predicate-literals.ts +27 -0
  86. package/src/synth/synthesize-from-mandate.ts +5 -5
  87. package/src/synth/synthesize-from-recording.ts +575 -18
  88. package/src/types.ts +19 -2
  89. package/src/verify/envelope.ts +28 -0
  90. package/src/verify/index.ts +5 -0
  91. package/src/verify/simulate.ts +292 -0
  92. package/src/verify/verify.ts +224 -0
@@ -1,27 +1,52 @@
1
1
  // src/synth/synthesize-from-recording.ts - recording-path orchestrator.
2
2
  //
3
3
  // `synthesizeFromRecording` is the second Synthesizer front-end: it INFERS a
4
- // bounded policy from a `RecordedTransaction` via the same `PolicyIR` +
5
- // `createOzAdapter` pair used by the deterministic Mandate path. The flow:
4
+ // bounded policy from a `RecordedTransaction` via the same `PolicyIR` + adapter
5
+ // pair used by the deterministic Mandate path. The flow:
6
6
  //
7
7
  // 1. parseConfidence gate - refuse when `overall < threshold` (default 1.0;
8
8
  // the caller may relax via `confidenceOverride.threshold`).
9
9
  // 2. `lower(tx)` -> IntentFacts.
10
10
  // 3. `decideScope(facts)` -> scope | SCOPE_UNRESOLVED ToolError.
11
- // 4. `composeFromRecording(facts, scope, opts)` -> PolicyIR + ambiguities.
11
+ // 4. `composeFromRecording(facts, scope, opts)` -> { ir (OZ-shape),
12
+ // interpreterIr (predicate-shape), ambiguities, warnings }.
12
13
  // 5. `ozAdapter.compile(ir)` -> CompileResult.
13
- // 6. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
14
- // (the OZ `uncovered` Path-B markers, prefixed), and ambiguities
15
- // (DURATION_UNSPECIFIED etc.).
14
+ // 6. If `interpreterIr.rules[0].constraints.length > 0` AND the caller
15
+ // opted in to the interpreter adapter:
16
+ // a. Run `interpreterAdapter.compile(interpreterIr)`.
17
+ // b. Throw -> SYNTHESIS_ERROR carrying the gate code
18
+ // (SCOPE_SELF_CALL, ORACLE_LEAF_INVALID_POSITION,
19
+ // ORACLE_PARAMS_OUT_OF_RANGE). Never install an OZ-only partial
20
+ // policy - a warning can be ignored and yields an over-broad rule.
21
+ // c. `covered === false` -> SYNTHESIS_ERROR carrying the uncovered
22
+ // descriptor (the user asked for the interpreter to enforce these
23
+ // constraints; refusing them silently is the audit failure mode).
24
+ // d. `covered === true` -> merge its PolicyDocument + PolicyRef into
25
+ // the OZ-shaped ProposedPolicy; the merged `contextRule.policies`
26
+ // is `[interpreterRef?, ...oz_builtinRefs]` and must satisfy
27
+ // `OZ_LIMITS.maxPoliciesPerRule` (= 5).
28
+ // 7. Assemble ProposedPolicy carrying parseConfidence (from tx), warnings
29
+ // (the OZ `uncovered` markers, prefixed), and ambiguities.
16
30
  //
17
31
  // Determinism: same (tx, opts, ozConfig) -> byte-identical ProposedPolicy.
18
32
  // No randomness, no clock, no globals.
19
33
 
34
+ import type { InterpreterAdapterConfig } from '../adapters/interpreter/adapter.ts'
35
+ import {
36
+ createInterpreterAdapter,
37
+ lowerRuleToPredicate,
38
+ PLACEHOLDER_INTERPRETER_ADDRESS,
39
+ } from '../adapters/interpreter/adapter.ts'
20
40
  import type { OzAdapterConfig } from '../adapters/oz/adapter.ts'
21
41
  import { createOzAdapter } from '../adapters/oz/adapter.ts'
22
42
  import type { ToolError, ToolResponse } from '../errors.ts'
43
+ import { encodePredicate } from '../predicate/encode.ts'
23
44
  import {
45
+ type ContractInvocation,
24
46
  type Network,
47
+ OZ_LIMITS,
48
+ type PredicateLeaf,
49
+ type PredicateNode,
25
50
  type ProposedPolicy,
26
51
  type RecordedTransaction,
27
52
  SOROBAN_LIMITS,
@@ -31,19 +56,50 @@ import {
31
56
  type ComposeUserResponses,
32
57
  composeFromRecording,
33
58
  } from './compose-from-recording.ts'
59
+ import { generateCases } from './deny-cases.ts'
60
+ import { type EvalContext, evaluate } from './evaluate.ts'
61
+ import { runHarness } from './harness.ts'
34
62
  import { lower } from './lower.ts'
63
+ import { minimize } from './minimize.ts'
35
64
  import { decideScope, type ScopeDecision } from './scope.ts'
36
65
 
37
- const PATH_B_PREFIX = 'Path B (not covered by OZ built-in primitives): '
66
+ const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: '
67
+
68
+ /** Per-policy interpreter adapter config the caller opts into. Absent ->
69
+ * recording path runs in week-1 mode (OZ adapter only, warnings for the
70
+ * constraints OZ cannot express). */
71
+ export interface InterpreterAdapterOptions {
72
+ /** The smart account this interpreter policy will be installed against.
73
+ * Must be a C... contract address - the on-chain policy-bound account.
74
+ * The G... source account from the recording is a separate concept. */
75
+ smartAccountAddress: string
76
+ /** Per-rule install nonce (first install = 1). Defaults to 1. */
77
+ installNonce?: number
78
+ /** Per-policy oracle overrides. Tighten-only vs the wasm defaults
79
+ * (maxStalenessSeconds <= 600, maxDeviationBps <= 200); widening is
80
+ * rejected at the options boundary. */
81
+ oracleParams?: { maxStalenessSeconds?: number; maxDeviationBps?: number }
82
+ /** @internal Test-only seam. When set, the orchestrator uses this
83
+ * PredicateNode as the starting point for self-verify + minimise instead
84
+ * of calling the interpreter adapter's compile(). Production callers MUST
85
+ * NOT set this; the field exists so the self-verify pipeline can be
86
+ * exercised on hand-crafted predicates (redundant conjuncts for
87
+ * minimise, deliberately over-broad leaves for DENY_CASE_FAILURE). The
88
+ * `smartAccountAddress` + `installNonce` + `oracleParams` are still
89
+ * carried through so the emitted PolicyRef + PolicyDocument match the
90
+ * recorded call shape. */
91
+ __testPredicateNode?: PredicateNode
92
+ }
38
93
 
39
94
  /** Top-level orchestrator inputs. `userResponses` carries the LLM-collected
40
95
  * answers to the ambiguity prompts (windowSeconds, validUntilLedger,
41
- * limitAmount, invocationLimit). `confidenceOverride` relaxes the recorder's
42
- * gate. */
96
+ * limitAmount, invocationLimit, oraclePriceBound, swapRecipientAllowlist).
97
+ * `interpreter` opts the recording path into the interpreter adapter. */
43
98
  export interface SynthesizeFromRecordingOptions {
44
99
  network: Network
45
100
  userResponses?: ComposeUserResponses
46
101
  confidenceOverride?: { threshold: number }
102
+ interpreter?: InterpreterAdapterOptions
47
103
  }
48
104
 
49
105
  /** Synthesize a ProposedPolicy from a recorded transaction. */
@@ -98,7 +154,7 @@ export function synthesizeFromRecording(
98
154
  if (!scopeRes.ok) return scopeRes
99
155
  const scope: ScopeDecision = scopeRes.data
100
156
  if (scope.kind !== 'call_contract') {
101
- // A default scope on the recording-path is not currently a Path-A flow;
157
+ // A default scope on the recording-path is not currently a covered flow;
102
158
  // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
103
159
  // over-broad default-scoped policy.
104
160
  return {
@@ -116,13 +172,14 @@ export function synthesizeFromRecording(
116
172
  const topLevel = tx.invocations[0] ?? null
117
173
  const composeOpts: ComposeOptions = {
118
174
  network: opts.network,
175
+ interpreterEnabled: opts.interpreter !== undefined,
119
176
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
120
177
  }
121
178
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
122
179
 
123
- // 5. OZ compile.
124
- const adapter = createOzAdapter(ozConfig)
125
- const compileRes = adapter.compile(composed.ir)
180
+ // 5. OZ compile (always runs).
181
+ const ozAdapter = createOzAdapter(ozConfig)
182
+ const compileRes = ozAdapter.compile(composed.ir)
126
183
 
127
184
  if (!compileRes.proposed) {
128
185
  return {
@@ -137,22 +194,331 @@ export function synthesizeFromRecording(
137
194
  }
138
195
  }
139
196
 
140
- // 6. assemble ProposedPolicy.
197
+ // 6. Interpreter compile (opt-in; fail-closed when the user routed real
198
+ // restrictions to the interpreter).
199
+ //
200
+ // When the caller opts in, the interpreter always runs - even for a
201
+ // scope-only interpreter IR (no routed constraints) - because the
202
+ // interpreter adapter lowers `scope.contract` + `scope.method` into a
203
+ // `call_contract == ...` and `call_fn == ...` predicate sibling pair.
204
+ // This is the real per-method enforcement the OZ adapter cannot do.
205
+ //
206
+ // When the caller does NOT opt in: today's behaviour is preserved. The
207
+ // compose step routes every constraint to the OZ IR; OZ flags the ones
208
+ // it cannot lower as `uncovered` and the orchestrator surfaces them as
209
+ // warnings. No interpreter doc is produced.
210
+ const interpreterOpts = opts.interpreter
211
+
212
+ let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
213
+ let interpreterPolicyRef: ProposedPolicy['policyRefs'][number] | null = null
214
+
215
+ if (interpreterOpts) {
216
+ const interpreterConfig: InterpreterAdapterConfig = {
217
+ network: opts.network,
218
+ installNonce: interpreterOpts.installNonce ?? 1,
219
+ smartAccountAddress: interpreterOpts.smartAccountAddress,
220
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
221
+ }
222
+
223
+ // The starting PredicateNode the self-verify + minimise pipeline drives.
224
+ // Two paths populate it:
225
+ // - Test seam (`__testPredicateNode` set): use it directly, skipping the
226
+ // adapter's compile(). Production code MUST NOT set the seam; it
227
+ // exists so the pipeline can be exercised on hand-crafted predicates
228
+ // (redundant conjuncts for minimise, deliberately over-broad leaves
229
+ // for DENY_CASE_FAILURE).
230
+ // - Normal: call the interpreter adapter's compile() so the existing
231
+ // enforcement gates (SCOPE_SELF_CALL / ORACLE_LEAF_INVALID_POSITION /
232
+ // ORACLE_PARAMS_OUT_OF_RANGE + uncovered reporting) fire first, then
233
+ // re-derive the pre-encoding PredicateNode via `lowerRuleToPredicate`
234
+ // so minimise + harness see the same shape the encoder will see.
235
+ let startingPredicate: PredicateNode | null = null
236
+ const testSeam = interpreterOpts.__testPredicateNode
237
+ if (testSeam !== undefined) {
238
+ startingPredicate = testSeam
239
+ } else {
240
+ const interpreterAdapter = createInterpreterAdapter(interpreterConfig)
241
+
242
+ let interpreterRes: Awaited<ReturnType<typeof interpreterAdapter.compile>>
243
+ try {
244
+ interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
245
+ } catch (e) {
246
+ // Interpreter enforcement-gate throws (SCOPE_SELF_CALL,
247
+ // ORACLE_LEAF_INVALID_POSITION, ORACLE_PARAMS_OUT_OF_RANGE). Surface
248
+ // the gate code so the caller can act; never install an OZ-only
249
+ // partial policy - the user explicitly asked for these constraints
250
+ // to be enforced and silently dropping them yields an over-broad rule.
251
+ const code = (e as { code?: ToolError['code'] }).code
252
+ const allowedCodes: ToolError['code'][] = [
253
+ 'SCOPE_SELF_CALL',
254
+ 'ORACLE_LEAF_INVALID_POSITION',
255
+ 'ORACLE_PARAMS_OUT_OF_RANGE',
256
+ 'MALFORMED_PREDICATE',
257
+ 'PREDICATE_TOO_LARGE',
258
+ 'PREDICATE_TOO_DEEP',
259
+ 'TOO_MANY_LEAVES',
260
+ 'IN_OPERAND_LIMIT',
261
+ 'PREDICATE_ORACLE_OVER_LIMIT',
262
+ ]
263
+ const surfacedCode = code && allowedCodes.includes(code) ? code : 'SYNTHESIS_ERROR'
264
+ return {
265
+ ok: false,
266
+ error: {
267
+ code: surfacedCode,
268
+ message: `interpreter predicate could not be compiled: ${(e as Error).message}`,
269
+ severity: 'error',
270
+ retryable: false,
271
+ },
272
+ }
273
+ }
274
+
275
+ if (!interpreterRes.covered) {
276
+ return {
277
+ ok: false,
278
+ error: {
279
+ code: 'SYNTHESIS_ERROR',
280
+ message: `interpreter predicate is not fully covered: ${interpreterRes.uncovered.join('; ')}`,
281
+ severity: 'error',
282
+ retryable: false,
283
+ details: { uncovered: interpreterRes.uncovered },
284
+ },
285
+ }
286
+ }
287
+
288
+ if (!interpreterRes.proposed) {
289
+ return {
290
+ ok: false,
291
+ error: {
292
+ code: 'SYNTHESIS_ERROR',
293
+ message: 'interpreter adapter returned no installable policy',
294
+ severity: 'error',
295
+ retryable: false,
296
+ },
297
+ }
298
+ }
299
+
300
+ interpreterPolicyDocument = interpreterRes.proposed.policyDocuments[0] ?? null
301
+ interpreterPolicyRef = interpreterRes.proposed.policyRefs[0] ?? null
302
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
303
+ return {
304
+ ok: false,
305
+ error: {
306
+ code: 'SYNTHESIS_ERROR',
307
+ message: 'interpreter adapter returned a policy missing the document or ref',
308
+ severity: 'error',
309
+ retryable: false,
310
+ },
311
+ }
312
+ }
313
+
314
+ const firstInterpreterRule = composed.interpreterIr.rules[0]
315
+ if (!firstInterpreterRule) {
316
+ return {
317
+ ok: false,
318
+ error: {
319
+ code: 'SYNTHESIS_ERROR',
320
+ message: 'interpreter IR has no rules to lower',
321
+ severity: 'error',
322
+ retryable: false,
323
+ },
324
+ }
325
+ }
326
+ startingPredicate = lowerRuleToPredicate(firstInterpreterRule, interpreterConfig)
327
+ }
328
+
329
+ if (!startingPredicate) {
330
+ return {
331
+ ok: false,
332
+ error: {
333
+ code: 'SYNTHESIS_ERROR',
334
+ message: 'interpreter predicate was not derived',
335
+ severity: 'error',
336
+ retryable: false,
337
+ },
338
+ }
339
+ }
340
+
341
+ // 6b. Self-verify + minimise. We always build the same permit EvalContext
342
+ // from the recorded tx + synthesised constraints so the harness
343
+ // reasons about the intended call (the only one the user actually
344
+ // recorded). The recorded call MUST permit under the predicate; the
345
+ // generated deny battery MUST all deny.
346
+ if (!topLevel) {
347
+ // The orchestrator guarantees this for the call_contract branch above;
348
+ // a default-scoped policy never reaches the interpreter block. Fail
349
+ // closed rather than attempt to self-verify a vague shape.
350
+ return {
351
+ ok: false,
352
+ error: {
353
+ code: 'SYNTHESIS_ERROR',
354
+ message: 'self-verify requires a recorded top-level invocation',
355
+ severity: 'error',
356
+ retryable: false,
357
+ },
358
+ }
359
+ }
360
+ if (scope.kind !== 'call_contract') {
361
+ return {
362
+ ok: false,
363
+ error: {
364
+ code: 'SYNTHESIS_ERROR',
365
+ message: 'self-verify requires a call_contract scope',
366
+ severity: 'error',
367
+ retryable: false,
368
+ },
369
+ }
370
+ }
371
+ const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
372
+
373
+ // Top-level `and` predicates are what generateCases + minimize assume;
374
+ // for any other shape we skip minimise (encode as-is) but still assert
375
+ // the permit case permits. An over-broad non-and predicate is still
376
+ // caught by the harness on the next step.
377
+ const finalPredicate: PredicateNode =
378
+ startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
379
+
380
+ const harnessCases = generateCases(finalPredicate, permitCtx)
381
+ const harnessResult = runHarness(finalPredicate, harnessCases)
382
+ if (!harnessResult.ok) {
383
+ return {
384
+ ok: false,
385
+ error: {
386
+ code: 'DENY_CASE_FAILURE',
387
+ message: `self-verify harness failed for the interpreter predicate (${harnessResult.failures.length} failure(s))`,
388
+ severity: 'error',
389
+ retryable: false,
390
+ details: { failures: harnessResult.failures },
391
+ },
392
+ }
393
+ }
394
+
395
+ const evalResult = evaluate(finalPredicate, permitCtx)
396
+ if (!evalResult.permit) {
397
+ return {
398
+ ok: false,
399
+ error: {
400
+ code: 'DENY_CASE_FAILURE',
401
+ message: `intended recorded call was denied by the interpreter predicate: ${evalResult.reason}`,
402
+ severity: 'error',
403
+ retryable: false,
404
+ details: { reason: evalResult.reason },
405
+ },
406
+ }
407
+ }
408
+
409
+ // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
410
+ // canonical bytes back onto the PolicyDocument + PolicyRef. minimize
411
+ // and encodePredicate are pure and deterministic, so the re-encoded
412
+ // doc is byte-identical to what compile() would have produced for
413
+ // the minimised tree.
414
+ const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
415
+
416
+ if (testSeam !== undefined) {
417
+ // Test seam: build the PolicyDocument + PolicyRef now (compile() was
418
+ // skipped). Shape mirrors the adapter's output exactly.
419
+ interpreterPolicyDocument = {
420
+ grammarVersion: 1,
421
+ installNonce: interpreterOpts.installNonce ?? 1,
422
+ encodedPredicate,
423
+ predicateHash,
424
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
425
+ }
426
+ interpreterPolicyRef = {
427
+ kind: 'interpreter',
428
+ interpreterAddress: PLACEHOLDER_INTERPRETER_ADDRESS,
429
+ predicateBlobBase64: encodedPredicate,
430
+ }
431
+ } else {
432
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
433
+ return {
434
+ ok: false,
435
+ error: {
436
+ code: 'SYNTHESIS_ERROR',
437
+ message: 'interpreter policy document or ref was lost before re-encode',
438
+ severity: 'error',
439
+ retryable: false,
440
+ },
441
+ }
442
+ }
443
+ interpreterPolicyDocument = {
444
+ ...interpreterPolicyDocument,
445
+ encodedPredicate,
446
+ predicateHash,
447
+ }
448
+ if (interpreterPolicyRef.kind === 'interpreter') {
449
+ interpreterPolicyRef = {
450
+ ...interpreterPolicyRef,
451
+ predicateBlobBase64: encodedPredicate,
452
+ }
453
+ }
454
+ }
455
+ }
456
+
457
+ // When the interpreter succeeds, OZ-side `uncovered` warnings that the
458
+ // interpreter actually lowered (per-method scoping, recipient allowlists,
459
+ // eq_seq paths, oracle_price / invocation_count / token-mismatch
460
+ // window_spent) are misleading: OZ really did not lower them, but the
461
+ // interpreter did. Drop those so the user-facing warnings reflect what is
462
+ // still UN-enforced, not what OZ alone could not do.
463
+ const ozUncovered = interpreterPolicyRef
464
+ ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
465
+ : compileRes.uncovered
466
+
467
+ // 7. Merge into the OZ-shaped ProposedPolicy. Order:
468
+ // [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
469
+ const ozRefs = compileRes.proposed.policyRefs
470
+ const mergedRefs: ProposedPolicy['policyRefs'] = []
471
+ if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
472
+ for (const r of ozRefs) mergedRefs.push(r)
473
+
474
+ if (mergedRefs.length > OZ_LIMITS.maxPoliciesPerRule) {
475
+ return {
476
+ ok: false,
477
+ error: {
478
+ code: 'POLICY_CAP_EXCEEDED',
479
+ message: `merged policy count ${mergedRefs.length} exceeds OZ maxPoliciesPerRule (${OZ_LIMITS.maxPoliciesPerRule})`,
480
+ severity: 'error',
481
+ retryable: false,
482
+ },
483
+ }
484
+ }
485
+
486
+ const mergedContextRule = {
487
+ ...compileRes.proposed.contextRule,
488
+ policies: mergedRefs,
489
+ }
490
+
141
491
  const proposed: ProposedPolicy = {
142
- ...compileRes.proposed,
492
+ contextRule: mergedContextRule,
493
+ policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
494
+ policyRefs: mergedRefs,
143
495
  parseConfidence: { ...tx.parseConfidence },
144
496
  warnings: [
145
- ...compileRes.uncovered.map((u) => `${PATH_B_PREFIX}${u}`),
146
- ...composed.warnings.map((w) => `${PATH_B_PREFIX}${w}`),
497
+ ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
498
+ ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
147
499
  ],
148
500
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
149
501
  }
150
502
  return { ok: true, data: proposed }
151
503
  }
152
504
 
505
+ /** OZ-side `uncovered` warning patterns that the interpreter adapter
506
+ * actually lowers when wired in. When the interpreter adapter succeeds, we
507
+ * drop matching entries from the OZ uncovered list so the user-facing
508
+ * warnings reflect what is still UN-enforced rather than what OZ alone
509
+ * could not do. Match the exact descriptor strings the OZ adapter emits
510
+ * (see `src/adapters/oz/adapter.ts#describeCondition` /
511
+ * `describeSelector`). */
512
+ const INTERPRETER_COVERED_OZ_PATTERN =
513
+ /^per-method scoping to|^value allowlist on arg|^exact ordered sequence on arg|^oracle price condition on|^invocation-count window|^spending_limit on token .+ needs a CallContract context scoped to that token/
514
+
153
515
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
154
516
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
155
- * positive i128 decimal string; network mainnet|testnet. */
517
+ * positive i128 decimal string; network mainnet|testnet. When the caller
518
+ * opts into the interpreter adapter, `smartAccountAddress` must be a C...
519
+ * contract address (the on-chain policy-bound account, NOT the G... source
520
+ * account from the recording), `installNonce` must be a positive integer
521
+ * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
156
522
  function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null {
157
523
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
158
524
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`)
@@ -189,6 +555,44 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
189
555
  )
190
556
  }
191
557
  }
558
+ if (opts.interpreter) {
559
+ const sa = opts.interpreter.smartAccountAddress
560
+ if (typeof sa !== 'string' || sa.length === 0) {
561
+ return synthesisError(
562
+ `interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
563
+ )
564
+ }
565
+ if (!isContractAddress(sa)) {
566
+ return synthesisError(
567
+ `interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
568
+ )
569
+ }
570
+ const nonce = opts.interpreter.installNonce
571
+ if (nonce !== undefined && !isPositiveInt(nonce)) {
572
+ return synthesisError(`interpreter.installNonce must be a positive integer, got: ${nonce}`)
573
+ }
574
+ const op = opts.interpreter.oracleParams
575
+ if (op) {
576
+ const MAX_STALE = 600
577
+ const MAX_DEV = 200
578
+ if (
579
+ op.maxStalenessSeconds !== undefined &&
580
+ (!isPositiveInt(op.maxStalenessSeconds) || op.maxStalenessSeconds > MAX_STALE)
581
+ ) {
582
+ return synthesisError(
583
+ `interpreter.oracleParams.maxStalenessSeconds must be a positive integer <= ${MAX_STALE} (tighten-only), got: ${op.maxStalenessSeconds}`
584
+ )
585
+ }
586
+ if (
587
+ op.maxDeviationBps !== undefined &&
588
+ (!isPositiveInt(op.maxDeviationBps) || op.maxDeviationBps > MAX_DEV)
589
+ ) {
590
+ return synthesisError(
591
+ `interpreter.oracleParams.maxDeviationBps must be a positive integer <= ${MAX_DEV} (tighten-only), got: ${op.maxDeviationBps}`
592
+ )
593
+ }
594
+ }
595
+ }
192
596
  return null
193
597
  }
194
598
 
@@ -206,6 +610,13 @@ function isPositiveI128(s: string): boolean {
206
610
  }
207
611
  }
208
612
 
613
+ /** True when `s` looks like a Stellar C... contract address (strkey). The
614
+ * source account from the recording is a G... account; the smart account is
615
+ * a separate C... contract. */
616
+ function isContractAddress(s: string): boolean {
617
+ return s.startsWith('C') && s.length === 56
618
+ }
619
+
209
620
  function synthesisError(message: string): ToolError {
210
621
  return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false }
211
622
  }
@@ -224,3 +635,149 @@ function mergeAmbiguities(
224
635
  }
225
636
  return out
226
637
  }
638
+
639
+ /** Build the permit `EvalContext` the self-verify harness drives. The shape
640
+ * mirrors the intended recorded call (the only call the user actually
641
+ * performed) so:
642
+ * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
643
+ * permits the recorded call it was derived from); a failure surfaces
644
+ * as DENY_CASE_FAILURE.
645
+ * - `generateCases(predicate, ctx)` produces a deny battery that reflects
646
+ * the actual recorded move (real amount, real args, real window start).
647
+ * Amounts are summed per-token over all movements of the recorded tx
648
+ * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
649
+ * contains a price+timestamp satisfying each `oracle_price` leaf in the
650
+ * predicate so the intended call permits under every bound; the harness
651
+ * then mutates those entries (stale / missing / deviation / paused) to
652
+ * exercise the ORACLE_* deny paths. */
653
+ function buildPermitContext(
654
+ tx: RecordedTransaction,
655
+ scope: Extract<ScopeDecision, { kind: 'call_contract' }>,
656
+ topLevel: ContractInvocation,
657
+ userResponses: ComposeUserResponses | undefined,
658
+ predicate: PredicateNode
659
+ ): EvalContext {
660
+ const amountByToken: Record<string, string> = {}
661
+ const totals = new Map<string, bigint>()
662
+ for (const m of tx.tokenMovements) {
663
+ const current = totals.get(m.token) ?? 0n
664
+ totals.set(m.token, current + BigInt(m.amount))
665
+ }
666
+ for (const [token, total] of totals) {
667
+ amountByToken[token] = total.toString()
668
+ }
669
+
670
+ const oraclePriceByAsset: EvalContext['oraclePriceByAsset'] = oracleSatisfyingPrices(
671
+ predicate,
672
+ tx.fetchedAt
673
+ )
674
+
675
+ const ctx: EvalContext = {
676
+ contract: scope.contract,
677
+ fn: topLevel.fn,
678
+ args: topLevel.args.map(cloneScVal),
679
+ atLedger: tx.ledgerSequence,
680
+ nowSeconds: tx.fetchedAt,
681
+ amountByToken,
682
+ windowSpentByToken: {},
683
+ invocationCountByWindow: {},
684
+ oraclePriceByAsset,
685
+ }
686
+ if (userResponses?.validUntilLedger !== undefined) {
687
+ ctx.validUntilLedger = userResponses.validUntilLedger
688
+ }
689
+ return ctx
690
+ }
691
+
692
+ function cloneScVal(value: { type: string; value: unknown }): ContractInvocation['args'][number] {
693
+ // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
694
+ // (decoded by the recorder). We clone top-level shells so the harness can
695
+ // mutate deny cases without aliasing the recorded call.
696
+ if (value.type === 'vec') {
697
+ return {
698
+ type: 'vec',
699
+ value: (value.value as ContractInvocation['args'][number][]).map(cloneScVal),
700
+ }
701
+ }
702
+ return { ...value } as ContractInvocation['args'][number]
703
+ }
704
+
705
+ /** Walk every `oracle_price` leaf in the predicate and return a price map
706
+ * whose entries satisfy the bound so the intended call permits. The
707
+ * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
708
+ * fresh-oracle deny case in `generateCases` is the only path that flips
709
+ * this map. Negatives are clamped at 0 - oracle prices are non-negative on
710
+ * Stellar. */
711
+ function oracleSatisfyingPrices(
712
+ predicate: PredicateNode,
713
+ nowSeconds: number
714
+ ): EvalContext['oraclePriceByAsset'] {
715
+ const out: EvalContext['oraclePriceByAsset'] = {}
716
+ visitOracleLeaves(predicate, (asset, op, bound) => {
717
+ let price: bigint
718
+ switch (op) {
719
+ case 'lt':
720
+ case 'gt':
721
+ price = op === 'lt' ? bound - 1n : bound + 1n
722
+ break
723
+ case 'lte':
724
+ case 'gte':
725
+ case 'eq':
726
+ price = bound
727
+ break
728
+ }
729
+ if (price < 0n) price = 0n
730
+ out[asset] = { price: price.toString(), timestampSeconds: nowSeconds }
731
+ })
732
+ return out
733
+ }
734
+
735
+ function visitOracleLeaves(
736
+ node: PredicateNode,
737
+ visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void
738
+ ): void {
739
+ switch (node.op) {
740
+ case 'and':
741
+ case 'or':
742
+ for (const child of node.children) visitOracleLeaves(child, visit)
743
+ return
744
+ case 'not':
745
+ visitOracleLeaves(node.child, visit)
746
+ return
747
+ case 'eq':
748
+ case 'lt':
749
+ case 'lte':
750
+ case 'gt':
751
+ case 'gte': {
752
+ const leftLeaf: PredicateLeaf = node.left
753
+ const rightLeaf: PredicateLeaf = node.right
754
+ const leftIsOracle = leftLeaf.kind === 'oracle_price'
755
+ const rightIsOracle = rightLeaf.kind === 'oracle_price'
756
+ let oracleAsset: string | undefined
757
+ let literal: bigint | undefined
758
+ if (leftIsOracle) {
759
+ oracleAsset = leftLeaf.asset
760
+ literal = oracleLiteralFromLeaf(rightLeaf)
761
+ } else if (rightIsOracle) {
762
+ oracleAsset = rightLeaf.asset
763
+ literal = oracleLiteralFromLeaf(leftLeaf)
764
+ }
765
+ if (oracleAsset === undefined || literal === undefined) return
766
+ visit(oracleAsset, node.op, literal)
767
+ return
768
+ }
769
+ case 'in':
770
+ // `in` is pure membership; oracle leaves inside haystacks are
771
+ // forbidden by the position rule, so there's nothing to set up here.
772
+ return
773
+ }
774
+ }
775
+
776
+ function oracleLiteralFromLeaf(leaf: PredicateLeaf): bigint | undefined {
777
+ if (leaf.kind !== 'literal_i128') return undefined
778
+ try {
779
+ return BigInt(leaf.value)
780
+ } catch {
781
+ return undefined
782
+ }
783
+ }