@crediolabs/policy-synth 0.1.3 → 0.1.5

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 (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -5
  3. package/dist/adapters/interpreter/adapter.d.ts +38 -0
  4. package/dist/adapters/interpreter/adapter.js +522 -0
  5. package/dist/adapters/interpreter/index.d.ts +1 -0
  6. package/dist/adapters/interpreter/index.js +2 -0
  7. package/dist/adapters/oz/adapter.js +2 -0
  8. package/dist/codegen/compile-gate.d.ts +33 -0
  9. package/dist/codegen/compile-gate.js +119 -0
  10. package/dist/codegen/index.d.ts +2 -0
  11. package/dist/codegen/index.js +8 -0
  12. package/dist/codegen/template.d.ts +18 -0
  13. package/dist/codegen/template.js +131 -0
  14. package/dist/errors.d.ts +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/ir/types.d.ts +13 -2
  18. package/dist/predicate/encode.d.ts +10 -0
  19. package/dist/predicate/encode.js +249 -0
  20. package/dist/predicate/index.d.ts +1 -0
  21. package/dist/predicate/index.js +2 -0
  22. package/dist/record/decode.d.ts +6 -3
  23. package/dist/record/decode.js +13 -3
  24. package/dist/review-card/builder.d.ts +14 -0
  25. package/dist/review-card/builder.js +261 -0
  26. package/dist/review-card/conflict.d.ts +40 -0
  27. package/dist/review-card/conflict.js +111 -0
  28. package/dist/review-card/cross-check.d.ts +11 -0
  29. package/dist/review-card/cross-check.js +148 -0
  30. package/dist/review-card/index.d.ts +3 -0
  31. package/dist/review-card/index.js +4 -0
  32. package/dist/run/index.d.ts +31 -0
  33. package/dist/run/index.js +164 -0
  34. package/dist/run/schemas.d.ts +2003 -0
  35. package/dist/run/schemas.js +215 -0
  36. package/dist/synth/compose-from-recording.d.ts +35 -7
  37. package/dist/synth/compose-from-recording.js +225 -34
  38. package/dist/synth/deny-cases.d.ts +12 -0
  39. package/dist/synth/deny-cases.js +377 -0
  40. package/dist/synth/evaluate.d.ts +39 -0
  41. package/dist/synth/evaluate.js +425 -0
  42. package/dist/synth/harness.d.ts +16 -0
  43. package/dist/synth/harness.js +26 -0
  44. package/dist/synth/index.d.ts +4 -0
  45. package/dist/synth/index.js +4 -0
  46. package/dist/synth/minimize.d.ts +4 -0
  47. package/dist/synth/minimize.js +38 -0
  48. package/dist/synth/predicate-literals.d.ts +5 -0
  49. package/dist/synth/predicate-literals.js +25 -0
  50. package/dist/synth/synthesize-from-recording.d.ts +37 -5
  51. package/dist/synth/synthesize-from-recording.js +550 -18
  52. package/dist/types.d.ts +40 -1
  53. package/dist/types.js +17 -0
  54. package/dist/verify/envelope.d.ts +15 -0
  55. package/dist/verify/envelope.js +22 -0
  56. package/dist/verify/index.d.ts +3 -0
  57. package/dist/verify/index.js +3 -0
  58. package/dist/verify/simulate.d.ts +31 -0
  59. package/dist/verify/simulate.js +258 -0
  60. package/dist/verify/verify.d.ts +21 -0
  61. package/dist/verify/verify.js +189 -0
  62. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  63. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  64. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  65. package/dist-cjs/adapters/interpreter/index.js +8 -0
  66. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  67. package/dist-cjs/adapters/oz/adapter.js +289 -0
  68. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  69. package/dist-cjs/adapters/oz/index.js +8 -0
  70. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  71. package/dist-cjs/codegen/compile-gate.js +123 -0
  72. package/dist-cjs/codegen/index.d.ts +2 -0
  73. package/dist-cjs/codegen/index.js +14 -0
  74. package/dist-cjs/codegen/template.d.ts +18 -0
  75. package/dist-cjs/codegen/template.js +134 -0
  76. package/dist-cjs/errors.d.ts +37 -0
  77. package/dist-cjs/errors.js +3 -0
  78. package/dist-cjs/index.d.ts +11 -0
  79. package/dist-cjs/index.js +27 -0
  80. package/dist-cjs/ir/index.d.ts +1 -0
  81. package/dist-cjs/ir/index.js +3 -0
  82. package/dist-cjs/ir/types.d.ts +108 -0
  83. package/dist-cjs/ir/types.js +12 -0
  84. package/dist-cjs/mandate/index.d.ts +2 -0
  85. package/dist-cjs/mandate/index.js +6 -0
  86. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  87. package/dist-cjs/mandate/to-ir.js +63 -0
  88. package/dist-cjs/mandate/types.d.ts +20 -0
  89. package/dist-cjs/mandate/types.js +9 -0
  90. package/dist-cjs/package.json +3 -0
  91. package/dist-cjs/predicate/encode.d.ts +10 -0
  92. package/dist-cjs/predicate/encode.js +252 -0
  93. package/dist-cjs/predicate/index.d.ts +1 -0
  94. package/dist-cjs/predicate/index.js +6 -0
  95. package/dist-cjs/record/decode.d.ts +79 -0
  96. package/dist-cjs/record/decode.js +398 -0
  97. package/dist-cjs/record/freshness.d.ts +17 -0
  98. package/dist-cjs/record/freshness.js +55 -0
  99. package/dist-cjs/record/index.d.ts +21 -0
  100. package/dist-cjs/record/index.js +166 -0
  101. package/dist-cjs/record/movements.d.ts +20 -0
  102. package/dist-cjs/record/movements.js +192 -0
  103. package/dist-cjs/record/rpc.d.ts +22 -0
  104. package/dist-cjs/record/rpc.js +74 -0
  105. package/dist-cjs/record/validate.d.ts +22 -0
  106. package/dist-cjs/record/validate.js +63 -0
  107. package/dist-cjs/registry/identify.d.ts +11 -0
  108. package/dist-cjs/registry/identify.js +87 -0
  109. package/dist-cjs/registry/index.d.ts +3 -0
  110. package/dist-cjs/registry/index.js +15 -0
  111. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  112. package/dist-cjs/registry/known-addresses.js +53 -0
  113. package/dist-cjs/registry/protocols.d.ts +38 -0
  114. package/dist-cjs/registry/protocols.js +153 -0
  115. package/dist-cjs/review-card/builder.d.ts +14 -0
  116. package/dist-cjs/review-card/builder.js +264 -0
  117. package/dist-cjs/review-card/conflict.d.ts +40 -0
  118. package/dist-cjs/review-card/conflict.js +114 -0
  119. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  120. package/dist-cjs/review-card/cross-check.js +151 -0
  121. package/dist-cjs/review-card/index.d.ts +3 -0
  122. package/dist-cjs/review-card/index.js +10 -0
  123. package/dist-cjs/run/index.d.ts +31 -0
  124. package/dist-cjs/run/index.js +178 -0
  125. package/dist-cjs/run/schemas.d.ts +2003 -0
  126. package/dist-cjs/run/schemas.js +218 -0
  127. package/dist-cjs/seams/index.d.ts +1 -0
  128. package/dist-cjs/seams/index.js +3 -0
  129. package/dist-cjs/seams/types.d.ts +66 -0
  130. package/dist-cjs/seams/types.js +12 -0
  131. package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
  132. package/dist-cjs/synth/compose-from-recording.js +356 -0
  133. package/dist-cjs/synth/deny-cases.d.ts +12 -0
  134. package/dist-cjs/synth/deny-cases.js +380 -0
  135. package/dist-cjs/synth/evaluate.d.ts +39 -0
  136. package/dist-cjs/synth/evaluate.js +428 -0
  137. package/dist-cjs/synth/harness.d.ts +16 -0
  138. package/dist-cjs/synth/harness.js +29 -0
  139. package/dist-cjs/synth/index.d.ts +9 -0
  140. package/dist-cjs/synth/index.js +23 -0
  141. package/dist-cjs/synth/lower.d.ts +23 -0
  142. package/dist-cjs/synth/lower.js +119 -0
  143. package/dist-cjs/synth/minimize.d.ts +4 -0
  144. package/dist-cjs/synth/minimize.js +41 -0
  145. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  146. package/dist-cjs/synth/predicate-literals.js +28 -0
  147. package/dist-cjs/synth/scope.d.ts +26 -0
  148. package/dist-cjs/synth/scope.js +81 -0
  149. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  150. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  151. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  152. package/dist-cjs/synth/synthesize-from-recording.js +711 -0
  153. package/dist-cjs/types.d.ts +288 -0
  154. package/dist-cjs/types.js +55 -0
  155. package/dist-cjs/verify/envelope.d.ts +15 -0
  156. package/dist-cjs/verify/envelope.js +23 -0
  157. package/dist-cjs/verify/index.d.ts +3 -0
  158. package/dist-cjs/verify/index.js +8 -0
  159. package/dist-cjs/verify/simulate.d.ts +31 -0
  160. package/dist-cjs/verify/simulate.js +261 -0
  161. package/dist-cjs/verify/verify.d.ts +21 -0
  162. package/dist-cjs/verify/verify.js +192 -0
  163. package/package.json +43 -3
  164. package/src/adapters/interpreter/adapter.ts +642 -0
  165. package/src/adapters/interpreter/index.ts +8 -0
  166. package/src/adapters/oz/adapter.ts +2 -0
  167. package/src/codegen/compile-gate.ts +162 -0
  168. package/src/codegen/index.ts +17 -0
  169. package/src/codegen/template.ts +148 -0
  170. package/src/errors.ts +2 -0
  171. package/src/index.ts +2 -0
  172. package/src/ir/types.ts +9 -2
  173. package/src/predicate/encode.ts +307 -0
  174. package/src/predicate/index.ts +3 -0
  175. package/src/record/decode.ts +21 -10
  176. package/src/review-card/builder.ts +303 -0
  177. package/src/review-card/conflict.ts +143 -0
  178. package/src/review-card/cross-check.ts +158 -0
  179. package/src/review-card/index.ts +12 -0
  180. package/src/run/index.ts +213 -0
  181. package/src/run/schemas.ts +242 -0
  182. package/src/synth/compose-from-recording.ts +277 -43
  183. package/src/synth/deny-cases.ts +466 -0
  184. package/src/synth/evaluate.ts +493 -0
  185. package/src/synth/harness.ts +40 -0
  186. package/src/synth/index.ts +12 -0
  187. package/src/synth/minimize.ts +44 -0
  188. package/src/synth/predicate-literals.ts +27 -0
  189. package/src/synth/synthesize-from-recording.ts +655 -19
  190. package/src/types.ts +38 -2
  191. package/src/verify/envelope.ts +28 -0
  192. package/src/verify/index.ts +5 -0
  193. package/src/verify/simulate.ts +311 -0
  194. package/src/verify/verify.ts +243 -0
@@ -1,33 +1,117 @@
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` 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.
33
+ import { createInterpreterAdapter, lowerRuleToPredicate, PLACEHOLDER_INTERPRETER_ADDRESS, } from "../adapters/interpreter/adapter.js";
19
34
  import { createOzAdapter } from "../adapters/oz/adapter.js";
20
- import { SOROBAN_LIMITS, } from "../types.js";
35
+ import { encodePredicate } from "../predicate/encode.js";
36
+ import { MAX_SCVAL_CLONE_DEPTH, OZ_LIMITS, SOROBAN_LIMITS, } from "../types.js";
21
37
  import { composeFromRecording, } from "./compose-from-recording.js";
38
+ import { generateCases } from "./deny-cases.js";
39
+ import { evaluate } from "./evaluate.js";
40
+ import { runHarness } from "./harness.js";
22
41
  import { lower } from "./lower.js";
42
+ import { minimize } from "./minimize.js";
23
43
  import { decideScope } from "./scope.js";
24
44
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
25
- /** Synthesize a ProposedPolicy from a recorded transaction. */
26
45
  export function synthesizeFromRecording(tx, opts, ozConfig) {
46
+ // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
47
+ // with a string `.code`) inside the body is converted to a structured
48
+ // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
49
+ // instead of being silently swallowed. The envelope must wrap the entire
50
+ // synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
51
+ // TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
52
+ // a structured ToolError rather than a thrown RangeError.
53
+ try {
54
+ return synthesizeFromRecordingInner(tx, opts, ozConfig);
55
+ }
56
+ catch (e) {
57
+ if (isToolErrorShape(e)) {
58
+ return {
59
+ ok: false,
60
+ error: {
61
+ code: e.code,
62
+ message: e.message,
63
+ severity: 'error',
64
+ retryable: false,
65
+ },
66
+ };
67
+ }
68
+ throw e;
69
+ }
70
+ }
71
+ /** True when `e` is the ToolError-shaped throw the internal pipeline uses to
72
+ * signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
73
+ * depth-cap throws). Detected by a string `code` field — the only contract
74
+ * the body's internal helpers agree on. */
75
+ function isToolErrorShape(e) {
76
+ if (e === null || typeof e !== 'object')
77
+ return false;
78
+ const code = e.code;
79
+ const message = e.message;
80
+ return typeof code === 'string' && typeof message === 'string';
81
+ }
82
+ /** ToolError-shaped error helper used by the body to surface a structured
83
+ * failure that the envelope converts to `{ok:false, error}`. */
84
+ function throwToolError(code, message) {
85
+ const err = new Error(message);
86
+ err.code = code;
87
+ err.severity = 'error';
88
+ err.retryable = false;
89
+ throw err;
90
+ }
91
+ /** Synthesize a ProposedPolicy from a recorded transaction. */
92
+ function synthesizeFromRecordingInner(tx, opts, ozConfig) {
27
93
  // 0. validate inputs (fail closed - never synthesize from garbage).
28
94
  const invalid = validateOptions(opts);
29
95
  if (invalid)
30
96
  return { ok: false, error: invalid };
97
+ // 0a. per-movement amount validation (item 2). Synthesizing from a recording
98
+ // whose `tokenMovements[].amount` does not match the canonical positive
99
+ // decimal integer format is a malformed-input failure, not a runtime
100
+ // one - reject it here so the downstream `BigInt(m.amount)` in
101
+ // `buildPermitContext` cannot throw a SyntaxError past the envelope.
102
+ for (const [i, m] of tx.tokenMovements.entries()) {
103
+ if (!/^[0-9]+$/.test(m.amount)) {
104
+ return {
105
+ ok: false,
106
+ error: {
107
+ code: 'RECORDING_VALIDATION_FAILED',
108
+ message: `tokenMovements[${i}].amount must be a positive decimal integer string, got: ${m.amount}`,
109
+ severity: 'error',
110
+ retryable: false,
111
+ },
112
+ };
113
+ }
114
+ }
31
115
  // 1. parseConfidence gate.
32
116
  const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed;
33
117
  if (tx.parseConfidence.overall < threshold) {
@@ -68,9 +152,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
68
152
  return scopeRes;
69
153
  const scope = scopeRes.data;
70
154
  if (scope.kind !== 'call_contract') {
71
- // A default scope on the recording-path is not currently a covered flow;
72
- // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
73
- // over-broad default-scoped policy.
74
155
  return {
75
156
  ok: false,
76
157
  error: {
@@ -85,12 +166,13 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
85
166
  const topLevel = tx.invocations[0] ?? null;
86
167
  const composeOpts = {
87
168
  network: opts.network,
169
+ interpreterEnabled: opts.interpreter !== undefined,
88
170
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
89
171
  };
90
172
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
91
- // 5. OZ compile.
92
- const adapter = createOzAdapter(ozConfig);
93
- const compileRes = adapter.compile(composed.ir);
173
+ // 5. OZ compile (always runs).
174
+ const ozAdapter = createOzAdapter(ozConfig);
175
+ const compileRes = ozAdapter.compile(composed.ir);
94
176
  if (!compileRes.proposed) {
95
177
  return {
96
178
  ok: false,
@@ -103,21 +185,271 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
103
185
  },
104
186
  };
105
187
  }
106
- // 6. assemble ProposedPolicy.
188
+ // 6. Interpreter compile (opt-in; fail-closed when the user routed real
189
+ // restrictions to the interpreter).
190
+ const interpreterOpts = opts.interpreter;
191
+ let interpreterPolicyDocument = null;
192
+ let interpreterPolicyRef = null;
193
+ if (interpreterOpts) {
194
+ const interpreterConfig = {
195
+ network: opts.network,
196
+ installNonce: interpreterOpts.installNonce ?? 1,
197
+ smartAccountAddress: interpreterOpts.smartAccountAddress,
198
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
199
+ };
200
+ let startingPredicate = null;
201
+ const testSeam = interpreterOpts.__testPredicateNode;
202
+ if (testSeam !== undefined) {
203
+ startingPredicate = testSeam;
204
+ }
205
+ else {
206
+ const interpreterAdapter = createInterpreterAdapter(interpreterConfig);
207
+ let interpreterRes;
208
+ try {
209
+ interpreterRes = interpreterAdapter.compile(composed.interpreterIr);
210
+ }
211
+ catch (e) {
212
+ const code = e.code;
213
+ const allowedCodes = [
214
+ 'SCOPE_SELF_CALL',
215
+ 'ORACLE_LEAF_INVALID_POSITION',
216
+ 'ORACLE_PARAMS_OUT_OF_RANGE',
217
+ 'MALFORMED_PREDICATE',
218
+ 'PREDICATE_TOO_LARGE',
219
+ 'PREDICATE_TOO_DEEP',
220
+ 'TOO_MANY_LEAVES',
221
+ 'IN_OPERAND_LIMIT',
222
+ 'PREDICATE_ORACLE_OVER_LIMIT',
223
+ ];
224
+ const surfacedCode = code && allowedCodes.includes(code) ? code : 'SYNTHESIS_ERROR';
225
+ return {
226
+ ok: false,
227
+ error: {
228
+ code: surfacedCode,
229
+ message: `interpreter predicate could not be compiled: ${e.message}`,
230
+ severity: 'error',
231
+ retryable: false,
232
+ },
233
+ };
234
+ }
235
+ if (!interpreterRes.covered) {
236
+ return {
237
+ ok: false,
238
+ error: {
239
+ code: 'SYNTHESIS_ERROR',
240
+ message: `interpreter predicate is not fully covered: ${interpreterRes.uncovered.join('; ')}`,
241
+ severity: 'error',
242
+ retryable: false,
243
+ details: { uncovered: interpreterRes.uncovered },
244
+ },
245
+ };
246
+ }
247
+ if (!interpreterRes.proposed) {
248
+ return {
249
+ ok: false,
250
+ error: {
251
+ code: 'SYNTHESIS_ERROR',
252
+ message: 'interpreter adapter returned no installable policy',
253
+ severity: 'error',
254
+ retryable: false,
255
+ },
256
+ };
257
+ }
258
+ interpreterPolicyDocument = interpreterRes.proposed.policyDocuments[0] ?? null;
259
+ interpreterPolicyRef = interpreterRes.proposed.policyRefs[0] ?? null;
260
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
261
+ return {
262
+ ok: false,
263
+ error: {
264
+ code: 'SYNTHESIS_ERROR',
265
+ message: 'interpreter adapter returned a policy missing the document or ref',
266
+ severity: 'error',
267
+ retryable: false,
268
+ },
269
+ };
270
+ }
271
+ const firstInterpreterRule = composed.interpreterIr.rules[0];
272
+ if (!firstInterpreterRule) {
273
+ return {
274
+ ok: false,
275
+ error: {
276
+ code: 'SYNTHESIS_ERROR',
277
+ message: 'interpreter IR has no rules to lower',
278
+ severity: 'error',
279
+ retryable: false,
280
+ },
281
+ };
282
+ }
283
+ startingPredicate = lowerRuleToPredicate(firstInterpreterRule, interpreterConfig);
284
+ }
285
+ if (!startingPredicate) {
286
+ return {
287
+ ok: false,
288
+ error: {
289
+ code: 'SYNTHESIS_ERROR',
290
+ message: 'interpreter predicate was not derived',
291
+ severity: 'error',
292
+ retryable: false,
293
+ },
294
+ };
295
+ }
296
+ // 6b. Self-verify + minimise.
297
+ if (!topLevel) {
298
+ return {
299
+ ok: false,
300
+ error: {
301
+ code: 'SYNTHESIS_ERROR',
302
+ message: 'self-verify requires a recorded top-level invocation',
303
+ severity: 'error',
304
+ retryable: false,
305
+ },
306
+ };
307
+ }
308
+ if (scope.kind !== 'call_contract') {
309
+ return {
310
+ ok: false,
311
+ error: {
312
+ code: 'SYNTHESIS_ERROR',
313
+ message: 'self-verify requires a call_contract scope',
314
+ severity: 'error',
315
+ retryable: false,
316
+ },
317
+ };
318
+ }
319
+ const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
320
+ const finalPredicate = startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate;
321
+ const harnessCases = generateCases(finalPredicate, permitCtx);
322
+ const harnessResult = runHarness(finalPredicate, harnessCases);
323
+ if (!harnessResult.ok) {
324
+ return {
325
+ ok: false,
326
+ error: {
327
+ code: 'DENY_CASE_FAILURE',
328
+ message: `self-verify harness failed for the interpreter predicate (${harnessResult.failures.length} failure(s))`,
329
+ severity: 'error',
330
+ retryable: false,
331
+ details: { failures: harnessResult.failures },
332
+ },
333
+ };
334
+ }
335
+ const evalResult = evaluate(finalPredicate, permitCtx);
336
+ if (!evalResult.permit) {
337
+ return {
338
+ ok: false,
339
+ error: {
340
+ code: 'DENY_CASE_FAILURE',
341
+ message: `intended recorded call was denied by the interpreter predicate: ${evalResult.reason}`,
342
+ severity: 'error',
343
+ retryable: false,
344
+ details: { reason: evalResult.reason },
345
+ },
346
+ };
347
+ }
348
+ // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
349
+ // canonical bytes back onto the PolicyDocument + PolicyRef. The
350
+ // `encodePredicate` helper throws ToolError-shaped errors on cap
351
+ // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
352
+ // envelope converts them to a structured `{ok:false, error}`.
353
+ const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate);
354
+ if (testSeam !== undefined) {
355
+ interpreterPolicyDocument = {
356
+ grammarVersion: 1,
357
+ installNonce: interpreterOpts.installNonce ?? 1,
358
+ encodedPredicate,
359
+ predicateHash,
360
+ ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
361
+ };
362
+ interpreterPolicyRef = {
363
+ kind: 'interpreter',
364
+ interpreterAddress: PLACEHOLDER_INTERPRETER_ADDRESS,
365
+ predicateBlobBase64: encodedPredicate,
366
+ };
367
+ }
368
+ else {
369
+ if (!interpreterPolicyDocument || !interpreterPolicyRef) {
370
+ return {
371
+ ok: false,
372
+ error: {
373
+ code: 'SYNTHESIS_ERROR',
374
+ message: 'interpreter policy document or ref was lost before re-encode',
375
+ severity: 'error',
376
+ retryable: false,
377
+ },
378
+ };
379
+ }
380
+ interpreterPolicyDocument = {
381
+ ...interpreterPolicyDocument,
382
+ encodedPredicate,
383
+ predicateHash,
384
+ };
385
+ if (interpreterPolicyRef.kind === 'interpreter') {
386
+ interpreterPolicyRef = {
387
+ ...interpreterPolicyRef,
388
+ predicateBlobBase64: encodedPredicate,
389
+ };
390
+ }
391
+ }
392
+ }
393
+ // When the interpreter succeeds, OZ-side `uncovered` warnings that the
394
+ // interpreter actually lowered are misleading: OZ really did not lower them,
395
+ // but the interpreter did. Drop those so the user-facing warnings reflect
396
+ // what is still UN-enforced, not what OZ alone could not do.
397
+ const ozUncovered = interpreterPolicyRef
398
+ ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
399
+ : compileRes.uncovered;
400
+ // 7. Merge into the OZ-shaped ProposedPolicy.
401
+ const ozRefs = compileRes.proposed.policyRefs;
402
+ const mergedRefs = [];
403
+ if (interpreterPolicyRef)
404
+ mergedRefs.push(interpreterPolicyRef);
405
+ for (const r of ozRefs)
406
+ mergedRefs.push(r);
407
+ if (mergedRefs.length > OZ_LIMITS.maxPoliciesPerRule) {
408
+ return {
409
+ ok: false,
410
+ error: {
411
+ code: 'POLICY_CAP_EXCEEDED',
412
+ message: `merged policy count ${mergedRefs.length} exceeds OZ maxPoliciesPerRule (${OZ_LIMITS.maxPoliciesPerRule})`,
413
+ severity: 'error',
414
+ retryable: false,
415
+ },
416
+ };
417
+ }
418
+ const mergedContextRule = {
419
+ ...compileRes.proposed.contextRule,
420
+ policies: mergedRefs,
421
+ };
107
422
  const proposed = {
108
- ...compileRes.proposed,
423
+ contextRule: mergedContextRule,
424
+ policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
425
+ policyRefs: mergedRefs,
109
426
  parseConfidence: { ...tx.parseConfidence },
110
427
  warnings: [
111
- ...compileRes.uncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
428
+ ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
112
429
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
113
430
  ],
114
431
  ambiguities: mergeAmbiguities(composed.ambiguities, scope.ambiguities),
115
432
  };
116
433
  return { ok: true, data: proposed };
117
434
  }
435
+ // Re-export the throwToolError helper for callers that need to surface a
436
+ // ToolError-shaped throw inside the body (e.g. tests).
437
+ export { throwToolError };
438
+ /** OZ-side `uncovered` warning patterns that the interpreter adapter
439
+ * actually lowers when wired in. When the interpreter adapter succeeds, we
440
+ * drop matching entries from the OZ uncovered list so the user-facing
441
+ * warnings reflect what is still UN-enforced rather than what OZ alone
442
+ * could not do. Match the exact descriptor strings the OZ adapter emits
443
+ * (see `src/adapters/oz/adapter.ts#describeCondition` /
444
+ * `describeSelector`). */
445
+ const INTERPRETER_COVERED_OZ_PATTERN = /^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/;
118
446
  /** Reject non-sane inputs before any policy is synthesized. windowSeconds /
119
447
  * validUntilLedger / invocationLimit must be positive integers; limitAmount a
120
- * positive i128 decimal string; network mainnet|testnet. */
448
+ * positive i128 decimal string; network mainnet|testnet. When the caller
449
+ * opts into the interpreter adapter, `smartAccountAddress` must be a C...
450
+ * contract address (the on-chain policy-bound account, NOT the G... source
451
+ * account from the recording), `installNonce` must be a positive integer
452
+ * (default 1), and `oracleParams` must tighten-only vs the wasm defaults. */
121
453
  function validateOptions(opts) {
122
454
  if (opts.network !== 'mainnet' && opts.network !== 'testnet') {
123
455
  return synthesisError(`network must be 'mainnet' or 'testnet', got: ${String(opts.network)}`);
@@ -144,8 +476,53 @@ function validateOptions(opts) {
144
476
  return synthesisError(`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`);
145
477
  }
146
478
  }
479
+ if (opts.interpreter) {
480
+ const sa = opts.interpreter.smartAccountAddress;
481
+ if (typeof sa !== 'string' || sa.length === 0) {
482
+ return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
483
+ }
484
+ // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
485
+ // C.../56-char shape check so a fixture/LLM-seam marker is reported
486
+ // with the specific placeholder error (not a generic "must be a
487
+ // C... contract" message). A real install routes a C... contract
488
+ // address derivable from the on-chain account; any
489
+ // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
490
+ // fixture marker that must never reach the install payload.
491
+ if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
492
+ return synthesisError(`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`);
493
+ }
494
+ if (!isContractAddress(sa)) {
495
+ return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
496
+ }
497
+ const nonce = opts.interpreter.installNonce;
498
+ // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
499
+ // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
500
+ // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
501
+ if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
502
+ return synthesisError(`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`);
503
+ }
504
+ const op = opts.interpreter.oracleParams;
505
+ if (op) {
506
+ const MAX_STALE = 600;
507
+ const MAX_DEV = 200;
508
+ if (op.maxStalenessSeconds !== undefined &&
509
+ (!isPositiveInt(op.maxStalenessSeconds) || op.maxStalenessSeconds > MAX_STALE)) {
510
+ return synthesisError(`interpreter.oracleParams.maxStalenessSeconds must be a positive integer <= ${MAX_STALE} (tighten-only), got: ${op.maxStalenessSeconds}`);
511
+ }
512
+ if (op.maxDeviationBps !== undefined &&
513
+ (!isPositiveInt(op.maxDeviationBps) || op.maxDeviationBps > MAX_DEV)) {
514
+ return synthesisError(`interpreter.oracleParams.maxDeviationBps must be a positive integer <= ${MAX_DEV} (tighten-only), got: ${op.maxDeviationBps}`);
515
+ }
516
+ }
517
+ }
147
518
  return null;
148
519
  }
520
+ /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
521
+ * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
522
+ * the interpreter-contract strkey; a real install must point at a C...
523
+ * contract address derivable from the on-chain account, never a
524
+ * fixture/LLM-seam marker. */
525
+ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
149
526
  function isPositiveInt(n) {
150
527
  return Number.isInteger(n) && n > 0;
151
528
  }
@@ -160,6 +537,12 @@ function isPositiveI128(s) {
160
537
  return false;
161
538
  }
162
539
  }
540
+ /** True when `s` looks like a Stellar C... contract address (strkey). The
541
+ * source account from the recording is a G... account; the smart account is
542
+ * a separate C... contract. */
543
+ function isContractAddress(s) {
544
+ return s.startsWith('C') && s.length === 56;
545
+ }
163
546
  function synthesisError(message) {
164
547
  return { code: 'SYNTHESIS_ERROR', message, severity: 'error', retryable: false };
165
548
  }
@@ -176,3 +559,152 @@ function mergeAmbiguities(...lists) {
176
559
  }
177
560
  return out;
178
561
  }
562
+ /** Build the permit `EvalContext` the self-verify harness drives. The shape
563
+ * mirrors the intended recorded call (the only call the user actually
564
+ * performed) so:
565
+ * - `evaluate(predicate, ctx).permit === true` must hold (the predicate
566
+ * permits the recorded call it was derived from); a failure surfaces
567
+ * as DENY_CASE_FAILURE.
568
+ * - `generateCases(predicate, ctx)` produces a deny battery that reflects
569
+ * the actual recorded move (real amount, real args, real window start).
570
+ * Amounts are summed per-token over all movements of the recorded tx
571
+ * (BigInt accumulation; never lossy). The `oraclePriceByAsset` map
572
+ * contains a price+timestamp satisfying each `oracle_price` leaf in the
573
+ * predicate so the intended call permits under every bound; the harness
574
+ * then mutates those entries (stale / missing / deviation / paused) to
575
+ * exercise the ORACLE_* deny paths. */
576
+ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
577
+ const amountByToken = {};
578
+ const totals = new Map();
579
+ for (const m of tx.tokenMovements) {
580
+ const current = totals.get(m.token) ?? 0n;
581
+ totals.set(m.token, current + BigInt(m.amount));
582
+ }
583
+ for (const [token, total] of totals) {
584
+ amountByToken[token] = total.toString();
585
+ }
586
+ const oraclePriceByAsset = oracleSatisfyingPrices(predicate, tx.fetchedAt);
587
+ const ctx = {
588
+ contract: scope.contract,
589
+ fn: topLevel.fn,
590
+ args: topLevel.args.map(cloneScVal),
591
+ atLedger: tx.ledgerSequence,
592
+ nowSeconds: tx.fetchedAt,
593
+ amountByToken,
594
+ windowSpentByToken: {},
595
+ invocationCountByWindow: {},
596
+ oraclePriceByAsset,
597
+ };
598
+ if (userResponses?.validUntilLedger !== undefined) {
599
+ ctx.validUntilLedger = userResponses.validUntilLedger;
600
+ }
601
+ return ctx;
602
+ }
603
+ function cloneScVal(value, depth = 0) {
604
+ // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
605
+ // (decoded by the recorder). We clone top-level shells so the harness can
606
+ // mutate deny cases without aliasing the recorded call. Recursion is
607
+ // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
608
+ // cannot RangeError the JS stack; the over-depth branch throws a
609
+ // ToolError-shaped error that the `synthesizeFromRecording` envelope
610
+ // (item 3) converts to `{ok:false, error}`.
611
+ if (value.type === 'vec') {
612
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
613
+ throw cloneDepthError(value);
614
+ }
615
+ return {
616
+ type: 'vec',
617
+ value: value.value.map((v) => cloneScVal(v, depth + 1)),
618
+ };
619
+ }
620
+ return { ...value };
621
+ }
622
+ /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
623
+ * The shape (object with string `code`) is what the `synthesizeFromRecording`
624
+ * envelope detects and converts to a structured `{ok:false, error}`. */
625
+ function cloneDepthError(value) {
626
+ const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
627
+ err.code = 'SYNTHESIS_ERROR';
628
+ err.severity = 'error';
629
+ err.retryable = false;
630
+ // Tag the offending value so failures are diagnosable.
631
+ err.depthContext = value.type;
632
+ throw err;
633
+ }
634
+ /** Walk every `oracle_price` leaf in the predicate and return a price map
635
+ * whose entries satisfy the bound so the intended call permits. The
636
+ * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
637
+ * fresh-oracle deny case in `generateCases` is the only path that flips
638
+ * this map. Negatives are clamped at 0 - oracle prices are non-negative on
639
+ * Stellar. */
640
+ function oracleSatisfyingPrices(predicate, nowSeconds) {
641
+ const out = {};
642
+ visitOracleLeaves(predicate, (asset, op, bound) => {
643
+ let price;
644
+ switch (op) {
645
+ case 'lt':
646
+ case 'gt':
647
+ price = op === 'lt' ? bound - 1n : bound + 1n;
648
+ break;
649
+ case 'lte':
650
+ case 'gte':
651
+ case 'eq':
652
+ price = bound;
653
+ break;
654
+ }
655
+ if (price < 0n)
656
+ price = 0n;
657
+ out[asset] = { price: price.toString(), timestampSeconds: nowSeconds };
658
+ });
659
+ return out;
660
+ }
661
+ function visitOracleLeaves(node, visit) {
662
+ switch (node.op) {
663
+ case 'and':
664
+ case 'or':
665
+ for (const child of node.children)
666
+ visitOracleLeaves(child, visit);
667
+ return;
668
+ case 'not':
669
+ visitOracleLeaves(node.child, visit);
670
+ return;
671
+ case 'eq':
672
+ case 'lt':
673
+ case 'lte':
674
+ case 'gt':
675
+ case 'gte': {
676
+ const leftLeaf = node.left;
677
+ const rightLeaf = node.right;
678
+ const leftIsOracle = leftLeaf.kind === 'oracle_price';
679
+ const rightIsOracle = rightLeaf.kind === 'oracle_price';
680
+ let oracleAsset;
681
+ let literal;
682
+ if (leftIsOracle) {
683
+ oracleAsset = leftLeaf.asset;
684
+ literal = oracleLiteralFromLeaf(rightLeaf);
685
+ }
686
+ else if (rightIsOracle) {
687
+ oracleAsset = rightLeaf.asset;
688
+ literal = oracleLiteralFromLeaf(leftLeaf);
689
+ }
690
+ if (oracleAsset === undefined || literal === undefined)
691
+ return;
692
+ visit(oracleAsset, node.op, literal);
693
+ return;
694
+ }
695
+ case 'in':
696
+ // `in` is pure membership; oracle leaves inside haystacks are
697
+ // forbidden by the position rule, so there's nothing to set up here.
698
+ return;
699
+ }
700
+ }
701
+ function oracleLiteralFromLeaf(leaf) {
702
+ if (leaf.kind !== 'literal_i128')
703
+ return undefined;
704
+ try {
705
+ return BigInt(leaf.value);
706
+ }
707
+ catch {
708
+ return undefined;
709
+ }
710
+ }