@crediolabs/policy-synth 0.1.4 → 0.1.6

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/dist/codegen/template.js +3 -3
  3. package/dist/record/decode.d.ts +6 -3
  4. package/dist/record/decode.js +56 -3
  5. package/dist/record/freshness.d.ts +14 -1
  6. package/dist/record/freshness.js +32 -2
  7. package/dist/record/index.d.ts +11 -0
  8. package/dist/record/index.js +25 -0
  9. package/dist/record/movements.d.ts +15 -3
  10. package/dist/record/movements.js +42 -7
  11. package/dist/run/index.d.ts +43 -0
  12. package/dist/run/index.js +222 -0
  13. package/dist/run/schemas.d.ts +2016 -0
  14. package/dist/run/schemas.js +223 -0
  15. package/dist/synth/address.d.ts +7 -0
  16. package/dist/synth/address.js +12 -0
  17. package/dist/synth/compose-from-recording.d.ts +4 -3
  18. package/dist/synth/compose-from-recording.js +16 -6
  19. package/dist/synth/deny-cases.d.ts +12 -2
  20. package/dist/synth/deny-cases.js +76 -4
  21. package/dist/synth/evaluate.js +2 -2
  22. package/dist/synth/index.d.ts +1 -0
  23. package/dist/synth/index.js +4 -0
  24. package/dist/synth/minimize.d.ts +1 -1
  25. package/dist/synth/minimize.js +3 -3
  26. package/dist/synth/synthesize-from-recording.d.ts +5 -2
  27. package/dist/synth/synthesize-from-recording.js +175 -69
  28. package/dist/types.d.ts +41 -1
  29. package/dist/types.js +17 -0
  30. package/dist/verify/simulate.js +18 -2
  31. package/dist/verify/verify.js +18 -2
  32. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  33. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  34. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  35. package/dist-cjs/adapters/interpreter/index.js +8 -0
  36. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  37. package/dist-cjs/adapters/oz/adapter.js +289 -0
  38. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  39. package/dist-cjs/adapters/oz/index.js +8 -0
  40. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  41. package/dist-cjs/codegen/compile-gate.js +123 -0
  42. package/dist-cjs/codegen/index.d.ts +2 -0
  43. package/dist-cjs/codegen/index.js +14 -0
  44. package/dist-cjs/codegen/template.d.ts +18 -0
  45. package/dist-cjs/codegen/template.js +134 -0
  46. package/dist-cjs/errors.d.ts +37 -0
  47. package/dist-cjs/errors.js +3 -0
  48. package/dist-cjs/index.d.ts +11 -0
  49. package/dist-cjs/index.js +27 -0
  50. package/dist-cjs/ir/index.d.ts +1 -0
  51. package/dist-cjs/ir/index.js +3 -0
  52. package/dist-cjs/ir/types.d.ts +108 -0
  53. package/dist-cjs/ir/types.js +12 -0
  54. package/dist-cjs/mandate/index.d.ts +2 -0
  55. package/dist-cjs/mandate/index.js +6 -0
  56. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  57. package/dist-cjs/mandate/to-ir.js +63 -0
  58. package/dist-cjs/mandate/types.d.ts +20 -0
  59. package/dist-cjs/mandate/types.js +9 -0
  60. package/dist-cjs/package.json +3 -0
  61. package/dist-cjs/predicate/encode.d.ts +10 -0
  62. package/dist-cjs/predicate/encode.js +252 -0
  63. package/dist-cjs/predicate/index.d.ts +1 -0
  64. package/dist-cjs/predicate/index.js +6 -0
  65. package/dist-cjs/record/decode.d.ts +79 -0
  66. package/dist-cjs/record/decode.js +441 -0
  67. package/dist-cjs/record/freshness.d.ts +30 -0
  68. package/dist-cjs/record/freshness.js +85 -0
  69. package/dist-cjs/record/index.d.ts +32 -0
  70. package/dist-cjs/record/index.js +191 -0
  71. package/dist-cjs/record/movements.d.ts +32 -0
  72. package/dist-cjs/record/movements.js +227 -0
  73. package/dist-cjs/record/rpc.d.ts +22 -0
  74. package/dist-cjs/record/rpc.js +74 -0
  75. package/dist-cjs/record/validate.d.ts +22 -0
  76. package/dist-cjs/record/validate.js +63 -0
  77. package/dist-cjs/registry/identify.d.ts +11 -0
  78. package/dist-cjs/registry/identify.js +87 -0
  79. package/dist-cjs/registry/index.d.ts +3 -0
  80. package/dist-cjs/registry/index.js +15 -0
  81. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  82. package/dist-cjs/registry/known-addresses.js +53 -0
  83. package/dist-cjs/registry/protocols.d.ts +38 -0
  84. package/dist-cjs/registry/protocols.js +153 -0
  85. package/dist-cjs/review-card/builder.d.ts +14 -0
  86. package/dist-cjs/review-card/builder.js +264 -0
  87. package/dist-cjs/review-card/conflict.d.ts +40 -0
  88. package/dist-cjs/review-card/conflict.js +114 -0
  89. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  90. package/dist-cjs/review-card/cross-check.js +151 -0
  91. package/dist-cjs/review-card/index.d.ts +3 -0
  92. package/dist-cjs/review-card/index.js +10 -0
  93. package/dist-cjs/run/index.d.ts +43 -0
  94. package/dist-cjs/run/index.js +237 -0
  95. package/dist-cjs/run/schemas.d.ts +2016 -0
  96. package/dist-cjs/run/schemas.js +226 -0
  97. package/dist-cjs/seams/index.d.ts +1 -0
  98. package/dist-cjs/seams/index.js +3 -0
  99. package/dist-cjs/seams/types.d.ts +66 -0
  100. package/dist-cjs/seams/types.js +12 -0
  101. package/dist-cjs/synth/address.d.ts +7 -0
  102. package/dist-cjs/synth/address.js +15 -0
  103. package/dist-cjs/synth/compose-from-recording.d.ts +65 -0
  104. package/dist-cjs/synth/compose-from-recording.js +366 -0
  105. package/dist-cjs/synth/deny-cases.d.ts +22 -0
  106. package/dist-cjs/synth/deny-cases.js +438 -0
  107. package/dist-cjs/synth/evaluate.d.ts +39 -0
  108. package/dist-cjs/synth/evaluate.js +428 -0
  109. package/dist-cjs/synth/harness.d.ts +16 -0
  110. package/dist-cjs/synth/harness.js +29 -0
  111. package/dist-cjs/synth/index.d.ts +10 -0
  112. package/dist-cjs/synth/index.js +28 -0
  113. package/dist-cjs/synth/lower.d.ts +23 -0
  114. package/dist-cjs/synth/lower.js +119 -0
  115. package/dist-cjs/synth/minimize.d.ts +4 -0
  116. package/dist-cjs/synth/minimize.js +41 -0
  117. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  118. package/dist-cjs/synth/predicate-literals.js +28 -0
  119. package/dist-cjs/synth/scope.d.ts +26 -0
  120. package/dist-cjs/synth/scope.js +81 -0
  121. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  122. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  123. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  124. package/dist-cjs/synth/synthesize-from-recording.js +759 -0
  125. package/dist-cjs/types.d.ts +311 -0
  126. package/dist-cjs/types.js +55 -0
  127. package/dist-cjs/verify/envelope.d.ts +15 -0
  128. package/dist-cjs/verify/envelope.js +23 -0
  129. package/dist-cjs/verify/index.d.ts +3 -0
  130. package/dist-cjs/verify/index.js +8 -0
  131. package/dist-cjs/verify/simulate.d.ts +31 -0
  132. package/dist-cjs/verify/simulate.js +261 -0
  133. package/dist-cjs/verify/verify.d.ts +21 -0
  134. package/dist-cjs/verify/verify.js +192 -0
  135. package/package.json +43 -3
  136. package/src/codegen/template.ts +3 -3
  137. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +171 -0
  138. package/src/record/corpus-fixtures.json +532 -0
  139. package/src/record/decode.ts +63 -10
  140. package/src/record/freshness.ts +34 -2
  141. package/src/record/index.ts +40 -0
  142. package/src/record/movements.ts +40 -7
  143. package/src/run/index.ts +277 -0
  144. package/src/run/schemas.ts +250 -0
  145. package/src/synth/address.ts +14 -0
  146. package/src/synth/compose-from-recording.ts +20 -9
  147. package/src/synth/deny-cases.ts +87 -4
  148. package/src/synth/evaluate.ts +2 -2
  149. package/src/synth/index.ts +4 -0
  150. package/src/synth/minimize.ts +7 -3
  151. package/src/synth/synthesize-from-recording.ts +200 -68
  152. package/src/types.ts +37 -1
  153. package/src/verify/simulate.ts +21 -2
  154. package/src/verify/verify.ts +21 -2
@@ -33,21 +33,85 @@
33
33
  import { createInterpreterAdapter, lowerRuleToPredicate, PLACEHOLDER_INTERPRETER_ADDRESS, } from "../adapters/interpreter/adapter.js";
34
34
  import { createOzAdapter } from "../adapters/oz/adapter.js";
35
35
  import { encodePredicate } from "../predicate/encode.js";
36
- import { OZ_LIMITS, SOROBAN_LIMITS, } from "../types.js";
36
+ import { MAX_SCVAL_CLONE_DEPTH, OZ_LIMITS, SOROBAN_LIMITS, } from "../types.js";
37
37
  import { composeFromRecording, } from "./compose-from-recording.js";
38
- import { generateCases } from "./deny-cases.js";
38
+ import { generateCases, ORIGINAL_DIMENSIONS } from "./deny-cases.js";
39
39
  import { evaluate } from "./evaluate.js";
40
40
  import { runHarness } from "./harness.js";
41
41
  import { lower } from "./lower.js";
42
42
  import { minimize } from "./minimize.js";
43
43
  import { decideScope } from "./scope.js";
44
44
  const UNCOVERED_PREFIX = 'Not covered by OZ built-in primitives: ';
45
- /** Synthesize a ProposedPolicy from a recorded transaction. */
46
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) {
47
93
  // 0. validate inputs (fail closed - never synthesize from garbage).
48
94
  const invalid = validateOptions(opts);
49
95
  if (invalid)
50
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
+ }
51
115
  // 1. parseConfidence gate.
52
116
  const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed;
53
117
  if (tx.parseConfidence.overall < threshold) {
@@ -62,6 +126,31 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
62
126
  },
63
127
  };
64
128
  }
129
+ // 1a. Zero-invocation refusal (item 1). A recording with zero contract
130
+ // invocations cleared the parseConfidence gate legitimately (the
131
+ // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
132
+ // policy must scope to an authorized contract call. Refuse before any
133
+ // lower/scope work so the failure is specific and actionable. The
134
+ // recorder's silence is also made visible via `parseConfidence.noInvocations`
135
+ // so consumers can pattern-match without inferring from `invocations: []`
136
+ // next to `overall: 1.0`. The message does NOT ask for an ABI - the
137
+ // failure mode is the recording shape, not decoding coverage.
138
+ const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0;
139
+ if (hasNoInvocations) {
140
+ return {
141
+ ok: false,
142
+ error: {
143
+ code: 'SYNTHESIS_ERROR',
144
+ message: 'Recording contains no contract invocation to scope a policy to. Re-record a transaction that invokes a Soroban contract function (e.g. an SAC/SEP-41 transfer, a Blend yield-claim, or a SoroSwap swap).',
145
+ severity: 'error',
146
+ retryable: false,
147
+ details: {
148
+ invocations: tx.invocations.length,
149
+ noInvocationsMarker: tx.parseConfidence.noInvocations === true,
150
+ },
151
+ },
152
+ };
153
+ }
65
154
  // Bound the recording size fail-closed (defense-in-depth for direct callers;
66
155
  // the MCP schema caps this too).
67
156
  if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
@@ -88,9 +177,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
88
177
  return scopeRes;
89
178
  const scope = scopeRes.data;
90
179
  if (scope.kind !== 'call_contract') {
91
- // A default scope on the recording-path is not currently a covered flow;
92
- // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
93
- // over-broad default-scoped policy.
94
180
  return {
95
181
  ok: false,
96
182
  error: {
@@ -126,17 +212,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
126
212
  }
127
213
  // 6. Interpreter compile (opt-in; fail-closed when the user routed real
128
214
  // restrictions to the interpreter).
129
- //
130
- // When the caller opts in, the interpreter always runs - even for a
131
- // scope-only interpreter IR (no routed constraints) - because the
132
- // interpreter adapter lowers `scope.contract` + `scope.method` into a
133
- // `call_contract == ...` and `call_fn == ...` predicate sibling pair.
134
- // This is the real per-method enforcement the OZ adapter cannot do.
135
- //
136
- // When the caller does NOT opt in: today's behaviour is preserved. The
137
- // compose step routes every constraint to the OZ IR; OZ flags the ones
138
- // it cannot lower as `uncovered` and the orchestrator surfaces them as
139
- // warnings. No interpreter doc is produced.
140
215
  const interpreterOpts = opts.interpreter;
141
216
  let interpreterPolicyDocument = null;
142
217
  let interpreterPolicyRef = null;
@@ -147,18 +222,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
147
222
  smartAccountAddress: interpreterOpts.smartAccountAddress,
148
223
  ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
149
224
  };
150
- // The starting PredicateNode the self-verify + minimise pipeline drives.
151
- // Two paths populate it:
152
- // - Test seam (`__testPredicateNode` set): use it directly, skipping the
153
- // adapter's compile(). Production code MUST NOT set the seam; it
154
- // exists so the pipeline can be exercised on hand-crafted predicates
155
- // (redundant conjuncts for minimise, deliberately over-broad leaves
156
- // for DENY_CASE_FAILURE).
157
- // - Normal: call the interpreter adapter's compile() so the existing
158
- // enforcement gates (SCOPE_SELF_CALL / ORACLE_LEAF_INVALID_POSITION /
159
- // ORACLE_PARAMS_OUT_OF_RANGE + uncovered reporting) fire first, then
160
- // re-derive the pre-encoding PredicateNode via `lowerRuleToPredicate`
161
- // so minimise + harness see the same shape the encoder will see.
162
225
  let startingPredicate = null;
163
226
  const testSeam = interpreterOpts.__testPredicateNode;
164
227
  if (testSeam !== undefined) {
@@ -171,11 +234,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
171
234
  interpreterRes = interpreterAdapter.compile(composed.interpreterIr);
172
235
  }
173
236
  catch (e) {
174
- // Interpreter enforcement-gate throws (SCOPE_SELF_CALL,
175
- // ORACLE_LEAF_INVALID_POSITION, ORACLE_PARAMS_OUT_OF_RANGE). Surface
176
- // the gate code so the caller can act; never install an OZ-only
177
- // partial policy - the user explicitly asked for these constraints
178
- // to be enforced and silently dropping them yields an over-broad rule.
179
237
  const code = e.code;
180
238
  const allowedCodes = [
181
239
  'SCOPE_SELF_CALL',
@@ -260,15 +318,8 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
260
318
  },
261
319
  };
262
320
  }
263
- // 6b. Self-verify + minimise. We always build the same permit EvalContext
264
- // from the recorded tx + synthesised constraints so the harness
265
- // reasons about the intended call (the only one the user actually
266
- // recorded). The recorded call MUST permit under the predicate; the
267
- // generated deny battery MUST all deny.
321
+ // 6b. Self-verify + minimise.
268
322
  if (!topLevel) {
269
- // The orchestrator guarantees this for the call_contract branch above;
270
- // a default-scoped policy never reaches the interpreter block. Fail
271
- // closed rather than attempt to self-verify a vague shape.
272
323
  return {
273
324
  ok: false,
274
325
  error: {
@@ -291,12 +342,10 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
291
342
  };
292
343
  }
293
344
  const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate);
294
- // Top-level `and` predicates are what generateCases + minimize assume;
295
- // for any other shape we skip minimise (encode as-is) but still assert
296
- // the permit case permits. An over-broad non-and predicate is still
297
- // caught by the harness on the next step.
298
- const finalPredicate = startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate;
299
- const harnessCases = generateCases(finalPredicate, permitCtx);
345
+ const finalPredicate = startingPredicate.op === 'and'
346
+ ? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
347
+ : startingPredicate;
348
+ const harnessCases = generateCases(finalPredicate, permitCtx, ORIGINAL_DIMENSIONS);
300
349
  const harnessResult = runHarness(finalPredicate, harnessCases);
301
350
  if (!harnessResult.ok) {
302
351
  return {
@@ -324,14 +373,12 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
324
373
  };
325
374
  }
326
375
  // 6c. Re-encode the (possibly minimised) PredicateNode and stamp the
327
- // canonical bytes back onto the PolicyDocument + PolicyRef. minimize
328
- // and encodePredicate are pure and deterministic, so the re-encoded
329
- // doc is byte-identical to what compile() would have produced for
330
- // the minimised tree.
376
+ // canonical bytes back onto the PolicyDocument + PolicyRef. The
377
+ // `encodePredicate` helper throws ToolError-shaped errors on cap
378
+ // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
379
+ // envelope converts them to a structured `{ok:false, error}`.
331
380
  const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate);
332
381
  if (testSeam !== undefined) {
333
- // Test seam: build the PolicyDocument + PolicyRef now (compile() was
334
- // skipped). Shape mirrors the adapter's output exactly.
335
382
  interpreterPolicyDocument = {
336
383
  grammarVersion: 1,
337
384
  installNonce: interpreterOpts.installNonce ?? 1,
@@ -371,16 +418,13 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
371
418
  }
372
419
  }
373
420
  // When the interpreter succeeds, OZ-side `uncovered` warnings that the
374
- // interpreter actually lowered (per-method scoping, recipient allowlists,
375
- // eq_seq paths, oracle_price / invocation_count / token-mismatch
376
- // window_spent) are misleading: OZ really did not lower them, but the
377
- // interpreter did. Drop those so the user-facing warnings reflect what is
378
- // still UN-enforced, not what OZ alone could not do.
421
+ // interpreter actually lowered are misleading: OZ really did not lower them,
422
+ // but the interpreter did. Drop those so the user-facing warnings reflect
423
+ // what is still UN-enforced, not what OZ alone could not do.
379
424
  const ozUncovered = interpreterPolicyRef
380
425
  ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
381
426
  : compileRes.uncovered;
382
- // 7. Merge into the OZ-shaped ProposedPolicy. Order:
383
- // [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
427
+ // 7. Merge into the OZ-shaped ProposedPolicy.
384
428
  const ozRefs = compileRes.proposed.policyRefs;
385
429
  const mergedRefs = [];
386
430
  if (interpreterPolicyRef)
@@ -402,12 +446,24 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
402
446
  ...compileRes.proposed.contextRule,
403
447
  policies: mergedRefs,
404
448
  };
449
+ // When nothing installable was synthesised (no interpreter doc AND no OZ
450
+ // policy refs), an empty `policies` array reads as "no restrictions" rather
451
+ // than "I synthesised nothing". Surface that explicitly so the empty result
452
+ // is never mistaken for a permissive policy - the context rule still exists,
453
+ // but it constrains nothing. (Kept as `{ok:true}` so the documented
454
+ // Path-A/Path-B demo behaviour is preserved - see F3.)
455
+ const zeroPolicyWarning = mergedRefs.length === 0 && !interpreterPolicyDocument
456
+ ? [
457
+ 'No policy constraints were synthesised: the call to this contract is UNCONSTRAINED by this policy. Enable the interpreter (supply a smart account) to enforce the surfaced constraints.',
458
+ ]
459
+ : [];
405
460
  const proposed = {
406
461
  contextRule: mergedContextRule,
407
462
  policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
408
463
  policyRefs: mergedRefs,
409
464
  parseConfidence: { ...tx.parseConfidence },
410
465
  warnings: [
466
+ ...zeroPolicyWarning,
411
467
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
412
468
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
413
469
  ],
@@ -415,6 +471,9 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
415
471
  };
416
472
  return { ok: true, data: proposed };
417
473
  }
474
+ // Re-export the throwToolError helper for callers that need to surface a
475
+ // ToolError-shaped throw inside the body (e.g. tests).
476
+ export { throwToolError };
418
477
  /** OZ-side `uncovered` warning patterns that the interpreter adapter
419
478
  * actually lowers when wired in. When the interpreter adapter succeeds, we
420
479
  * drop matching entries from the OZ uncovered list so the user-facing
@@ -453,7 +512,7 @@ function validateOptions(opts) {
453
512
  return synthesisError(`invocationLimit must be a positive integer, got: ${ur.invocationLimit}`);
454
513
  }
455
514
  if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
456
- return synthesisError(`limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`);
515
+ return synthesisError(`limitAmount must be a positive i128 decimal string within [1, ${I128_MAX}] (2^127-1), got: ${ur.limitAmount}`);
457
516
  }
458
517
  }
459
518
  if (opts.interpreter) {
@@ -461,12 +520,25 @@ function validateOptions(opts) {
461
520
  if (typeof sa !== 'string' || sa.length === 0) {
462
521
  return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
463
522
  }
523
+ // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
524
+ // C.../56-char shape check so a fixture/LLM-seam marker is reported
525
+ // with the specific placeholder error (not a generic "must be a
526
+ // C... contract" message). A real install routes a C... contract
527
+ // address derivable from the on-chain account; any
528
+ // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
529
+ // fixture marker that must never reach the install payload.
530
+ if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
531
+ return synthesisError(`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`);
532
+ }
464
533
  if (!isContractAddress(sa)) {
465
534
  return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
466
535
  }
467
536
  const nonce = opts.interpreter.installNonce;
468
- if (nonce !== undefined && !isPositiveInt(nonce)) {
469
- return synthesisError(`interpreter.installNonce must be a positive integer, got: ${nonce}`);
537
+ // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
538
+ // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
539
+ // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
540
+ if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
541
+ return synthesisError(`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`);
470
542
  }
471
543
  const op = opts.interpreter.oracleParams;
472
544
  if (op) {
@@ -484,15 +556,30 @@ function validateOptions(opts) {
484
556
  }
485
557
  return null;
486
558
  }
559
+ /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
560
+ * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
561
+ * the interpreter-contract strkey; a real install must point at a C...
562
+ * contract address derivable from the on-chain account, never a
563
+ * fixture/LLM-seam marker. */
564
+ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
565
+ /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
566
+ * cannot be represented on-chain, so reject it at the synthesis boundary
567
+ * (fail-closed) instead of passing it through as an over-broad spending_limit.
568
+ * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
569
+ const I128_MAX = 2n ** 127n - 1n;
487
570
  function isPositiveInt(n) {
488
571
  return Number.isInteger(n) && n > 0;
489
572
  }
490
- /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
573
+ /** True when `s` is a canonical positive decimal integer inside the signed-i128
574
+ * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
575
+ * it cannot be installed on-chain, and accepting it would emit a spending_limit
576
+ * with an effectively unbounded cap. */
491
577
  function isPositiveI128(s) {
492
578
  if (!/^[0-9]+$/.test(s))
493
579
  return false;
494
580
  try {
495
- return BigInt(s) > 0n;
581
+ const v = BigInt(s);
582
+ return v > 0n && v <= I128_MAX;
496
583
  }
497
584
  catch {
498
585
  return false;
@@ -561,18 +648,37 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
561
648
  }
562
649
  return ctx;
563
650
  }
564
- function cloneScVal(value) {
651
+ function cloneScVal(value, depth = 0) {
565
652
  // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
566
653
  // (decoded by the recorder). We clone top-level shells so the harness can
567
- // mutate deny cases without aliasing the recorded call.
654
+ // mutate deny cases without aliasing the recorded call. Recursion is
655
+ // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
656
+ // cannot RangeError the JS stack; the over-depth branch throws a
657
+ // ToolError-shaped error that the `synthesizeFromRecording` envelope
658
+ // (item 3) converts to `{ok:false, error}`.
568
659
  if (value.type === 'vec') {
660
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
661
+ throw cloneDepthError(value);
662
+ }
569
663
  return {
570
664
  type: 'vec',
571
- value: value.value.map(cloneScVal),
665
+ value: value.value.map((v) => cloneScVal(v, depth + 1)),
572
666
  };
573
667
  }
574
668
  return { ...value };
575
669
  }
670
+ /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
671
+ * The shape (object with string `code`) is what the `synthesizeFromRecording`
672
+ * envelope detects and converts to a structured `{ok:false, error}`. */
673
+ function cloneDepthError(value) {
674
+ const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
675
+ err.code = 'SYNTHESIS_ERROR';
676
+ err.severity = 'error';
677
+ err.retryable = false;
678
+ // Tag the offending value so failures are diagnosable.
679
+ err.depthContext = value.type;
680
+ throw err;
681
+ }
576
682
  /** Walk every `oracle_price` leaf in the predicate and return a price map
577
683
  * whose entries satisfy the bound so the intended call permits. The
578
684
  * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
package/dist/types.d.ts CHANGED
@@ -21,6 +21,17 @@ export type ScVal = {
21
21
  } | {
22
22
  type: 'bytes';
23
23
  value: string;
24
+ }
25
+ /** Map<Symbol, ScVal> carried forward for SAC/SEP-41 transfer event data,
26
+ * which uses a Map shape (key "amount" -> I128) on Stellar mainnet. The
27
+ * entry key is captured as its symbol/form string so readAmount can route
28
+ * to the conventional field name without re-decoding the full XDR. */
29
+ | {
30
+ type: 'map';
31
+ value: Array<{
32
+ key: string;
33
+ val: ScVal;
34
+ }>;
24
35
  } | {
25
36
  type: 'other';
26
37
  value: string;
@@ -42,6 +53,23 @@ export declare const SOROBAN_LIMITS: {
42
53
  * used to convert a spend window in seconds to OZ `period_ledgers`. */
43
54
  readonly secondsPerLedger: 5;
44
55
  };
56
+ /** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
57
+ * A real Stellar tx caps nesting well below this; the bound hard-caps a
58
+ * hand-crafted payload so the recorder fails CLOSED (records the over-depth
59
+ * branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
60
+ * Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
61
+ * already enforces; lifted to 30 so legitimate nested args (a path vec
62
+ * inside an order vec inside a market-data vec) decode cleanly while a
63
+ * malformed ~10000-deep payload still trips the cap. */
64
+ export declare const MAX_SCVAL_DEPTH: 30;
65
+ /** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
66
+ * the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
67
+ * so a hand-crafted nested-vec payload cannot RangeError the JS stack during
68
+ * context building. Over-depth throws a ToolError (caught by the
69
+ * `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
70
+ * the caller gets a structured `{ok:false, error}` instead of a thrown
71
+ * RangeError. */
72
+ export declare const MAX_SCVAL_CLONE_DEPTH: 30;
45
73
  /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
46
74
  * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
47
75
  * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
@@ -255,7 +283,15 @@ export interface AmbiguityPrompt {
255
283
  export type AmbiguityCode = 'DURATION_UNSPECIFIED' | 'AMOUNT_BOUND_MISSING' | 'RECIPIENT_ALLOWLIST_EMPTY' | 'FREQUENCY_BOUND_MISSING' | 'ORACLE_ASSET_UNKNOWN' | 'MULTIPLE_UNRELATED_TARGETS';
256
284
  /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
257
285
  * breakdown is the diagnostic the user / agent sees when the gate fires. The
258
- * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`. */
286
+ * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`.
287
+ *
288
+ * `noInvocations` (item 1) is a recorder-side marker set ONLY when the envelope
289
+ * contained zero `invokeContract` host functions (e.g. a `createContract` or
290
+ * `uploadContractWasm` op). The math is unchanged: that case still scores 1.0
291
+ * via the `denom === 0` guard. The marker exists so a consumer does not have
292
+ * to infer the situation from `invocations.length === 0` next to `overall: 1.0`
293
+ * — a downstream synth refuses such recordings with a specific, actionable
294
+ * error rather than silently producing a useless empty scope. */
259
295
  export interface ParseConfidence {
260
296
  overall: number;
261
297
  knownContracts: string[];
@@ -268,4 +304,8 @@ export interface ParseConfidence {
268
304
  type: string;
269
305
  }>;
270
306
  thresholdUsed: number;
307
+ /** True iff the recorder decoded zero contract invocations. Absent on
308
+ * recordings that pre-date this field; consumers should treat `undefined`
309
+ * as "no marker" and rely on `invocations.length` as the fallback. */
310
+ noInvocations?: true;
271
311
  }
package/dist/types.js CHANGED
@@ -17,6 +17,23 @@ export const SOROBAN_LIMITS = {
17
17
  * used to convert a spend window in seconds to OZ `period_ledgers`. */
18
18
  secondsPerLedger: 5,
19
19
  };
20
+ /** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
21
+ * A real Stellar tx caps nesting well below this; the bound hard-caps a
22
+ * hand-crafted payload so the recorder fails CLOSED (records the over-depth
23
+ * branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
24
+ * Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
25
+ * already enforces; lifted to 30 so legitimate nested args (a path vec
26
+ * inside an order vec inside a market-data vec) decode cleanly while a
27
+ * malformed ~10000-deep payload still trips the cap. */
28
+ export const MAX_SCVAL_DEPTH = 30;
29
+ /** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
30
+ * the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
31
+ * so a hand-crafted nested-vec payload cannot RangeError the JS stack during
32
+ * context building. Over-depth throws a ToolError (caught by the
33
+ * `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
34
+ * the caller gets a structured `{ok:false, error}` instead of a thrown
35
+ * RangeError. */
36
+ export const MAX_SCVAL_CLONE_DEPTH = 30;
20
37
  /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
21
38
  * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
22
39
  * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
@@ -28,6 +28,7 @@
28
28
  // suite to stay in lockstep.
29
29
  import { generateCases } from "../synth/deny-cases.js";
30
30
  import { evaluate } from "../synth/evaluate.js";
31
+ import { MAX_SCVAL_CLONE_DEPTH } from "../types.js";
31
32
  const SIMULATOR_VERSION = 'ts-model-1.0.0';
32
33
  /** Replay a recorded transaction against a proposed predicate and emit the
33
34
  * `SimulationResult` envelope. The simulator returns the SAME permit
@@ -177,12 +178,27 @@ function buildPermitContext(predicate, tx, topLevel, opts) {
177
178
  }
178
179
  return ctx;
179
180
  }
180
- function cloneScVal(value) {
181
+ function cloneScVal(value, depth = 0) {
182
+ // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
183
+ // payload cannot RangeError the JS stack during context building. Over-depth
184
+ // throws a ToolError-shaped error that the simulator's existing try/catch
185
+ // converts to a structured `{ok:false, error}` (not a thrown RangeError).
181
186
  if (value.type === 'vec') {
182
- return { type: 'vec', value: value.value.map(cloneScVal) };
187
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
188
+ throw cloneDepthError(value);
189
+ }
190
+ return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) };
183
191
  }
184
192
  return { ...value };
185
193
  }
194
+ function cloneDepthError(value) {
195
+ const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
196
+ err.code = 'SIMULATION_ERROR';
197
+ err.severity = 'error';
198
+ err.retryable = false;
199
+ err.depthContext = value.type;
200
+ throw err;
201
+ }
186
202
  function visitOracleLeaves(node, visit) {
187
203
  switch (node.op) {
188
204
  case 'and':
@@ -28,6 +28,7 @@
28
28
  //
29
29
  // Determinism: same `(predicate, permitTx)` -> byte-identical verdict.
30
30
  import { minimize } from "../synth/minimize.js";
31
+ import { MAX_SCVAL_CLONE_DEPTH } from "../types.js";
31
32
  /** Run the static minimality check on a proposed predicate + recorded tx.
32
33
  * Returns `{ ok: true }` when the predicate is minimal (every top-level
33
34
  * conjunct carries load) and `VERIFICATION_FAILED` when a conjunct could
@@ -125,12 +126,27 @@ function buildPermitContextForVerify(tx, topLevel, opts) {
125
126
  }
126
127
  return ctx;
127
128
  }
128
- function cloneScVal(value) {
129
+ function cloneScVal(value, depth = 0) {
130
+ // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
131
+ // payload cannot RangeError the JS stack during context building. Over-depth
132
+ // throws a ToolError-shaped error that the verifier's existing try/catch
133
+ // converts to a structured `{ok:false, error}` (not a thrown RangeError).
129
134
  if (value.type === 'vec') {
130
- return { type: 'vec', value: value.value.map(cloneScVal) };
135
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
136
+ throw cloneDepthError(value);
137
+ }
138
+ return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) };
131
139
  }
132
140
  return { ...value };
133
141
  }
142
+ function cloneDepthError(value) {
143
+ const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
144
+ err.code = 'VERIFICATION_FAILED';
145
+ err.severity = 'error';
146
+ err.retryable = false;
147
+ err.depthContext = value.type;
148
+ throw err;
149
+ }
134
150
  function fingerprint(node) {
135
151
  return JSON.stringify(node, replacer);
136
152
  }
@@ -0,0 +1,38 @@
1
+ import type { IRPolicyRule } from '../../ir/types.ts';
2
+ import type { CustodyAdapter } from '../../seams/types.ts';
3
+ import type { Network, PredicateNode } from '../../types.ts';
4
+ /** [VERIFY] NOT a real deployed address. The interpreter is a per-network
5
+ * deploy artifact we do not have yet; install is a later phase. */
6
+ export declare const PLACEHOLDER_INTERPRETER_ADDRESS = "VERIFY-interpreter-address";
7
+ /** Wasm-level oracle defaults (mirrors INTERPRETER_INSTALL_PARAMS.md OracleParams).
8
+ * Per-policy overrides may TIGHTEN only - they may never exceed these. */
9
+ export declare const ORACLE_DEFAULTS: {
10
+ readonly maxStalenessSeconds: 600;
11
+ readonly maxDeviationBps: 200;
12
+ };
13
+ export interface InterpreterAdapterConfig {
14
+ network: Network;
15
+ /** Per-rule install nonce (first install = 1; replay-protected). */
16
+ installNonce: number;
17
+ /** The smart account this interpreter policy will be installed against. Used
18
+ * by the self-call gate: any `in`-allowlist containing this address is
19
+ * rejected as `SCOPE_SELF_CALL`. */
20
+ smartAccountAddress: string;
21
+ /** Optional per-policy oracle overrides. Must TIGHTEN vs ORACLE_DEFAULTS
22
+ * (i.e. <= each default). A widening value throws ORACLE_PARAMS_OUT_OF_RANGE
23
+ * at compile time. */
24
+ oracleParams?: {
25
+ maxStalenessSeconds?: number;
26
+ maxDeviationBps?: number;
27
+ };
28
+ }
29
+ export declare function createInterpreterAdapter(config: InterpreterAdapterConfig): CustodyAdapter;
30
+ /** Lower a single IR rule to the canonical pre-encoding `PredicateNode`. The
31
+ * orchestrator uses this to wire the self-verify + minimise pipeline: after
32
+ * `compile(ir)` succeeds (`covered === true`, `proposed` set), the
33
+ * orchestrator re-derives the PredicateNode via this helper to drive
34
+ * `minimize` and `runHarness` on the SAME shape the encoder saw. Pure and
35
+ * deterministic: same `rule + config` -> byte-identical PredicateNode. The
36
+ * `uncovered` list the adapter surfaces during compile is NOT re-derived
37
+ * here; callers that need it should use the result of `compile(ir)`. */
38
+ export declare function lowerRuleToPredicate(rule: IRPolicyRule, config: InterpreterAdapterConfig): PredicateNode;