@crediolabs/policy-synth 0.1.4 → 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 (128) 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 +13 -3
  5. package/dist/run/index.d.ts +31 -0
  6. package/dist/run/index.js +164 -0
  7. package/dist/run/schemas.d.ts +2003 -0
  8. package/dist/run/schemas.js +215 -0
  9. package/dist/synth/deny-cases.js +18 -2
  10. package/dist/synth/evaluate.js +2 -2
  11. package/dist/synth/synthesize-from-recording.d.ts +5 -2
  12. package/dist/synth/synthesize-from-recording.js +121 -63
  13. package/dist/types.d.ts +17 -0
  14. package/dist/types.js +17 -0
  15. package/dist/verify/simulate.js +18 -2
  16. package/dist/verify/verify.js +18 -2
  17. package/dist-cjs/adapters/interpreter/adapter.d.ts +38 -0
  18. package/dist-cjs/adapters/interpreter/adapter.js +527 -0
  19. package/dist-cjs/adapters/interpreter/index.d.ts +1 -0
  20. package/dist-cjs/adapters/interpreter/index.js +8 -0
  21. package/dist-cjs/adapters/oz/adapter.d.ts +20 -0
  22. package/dist-cjs/adapters/oz/adapter.js +289 -0
  23. package/dist-cjs/adapters/oz/index.d.ts +1 -0
  24. package/dist-cjs/adapters/oz/index.js +8 -0
  25. package/dist-cjs/codegen/compile-gate.d.ts +33 -0
  26. package/dist-cjs/codegen/compile-gate.js +123 -0
  27. package/dist-cjs/codegen/index.d.ts +2 -0
  28. package/dist-cjs/codegen/index.js +14 -0
  29. package/dist-cjs/codegen/template.d.ts +18 -0
  30. package/dist-cjs/codegen/template.js +134 -0
  31. package/dist-cjs/errors.d.ts +37 -0
  32. package/dist-cjs/errors.js +3 -0
  33. package/dist-cjs/index.d.ts +11 -0
  34. package/dist-cjs/index.js +27 -0
  35. package/dist-cjs/ir/index.d.ts +1 -0
  36. package/dist-cjs/ir/index.js +3 -0
  37. package/dist-cjs/ir/types.d.ts +108 -0
  38. package/dist-cjs/ir/types.js +12 -0
  39. package/dist-cjs/mandate/index.d.ts +2 -0
  40. package/dist-cjs/mandate/index.js +6 -0
  41. package/dist-cjs/mandate/to-ir.d.ts +3 -0
  42. package/dist-cjs/mandate/to-ir.js +63 -0
  43. package/dist-cjs/mandate/types.d.ts +20 -0
  44. package/dist-cjs/mandate/types.js +9 -0
  45. package/dist-cjs/package.json +3 -0
  46. package/dist-cjs/predicate/encode.d.ts +10 -0
  47. package/dist-cjs/predicate/encode.js +252 -0
  48. package/dist-cjs/predicate/index.d.ts +1 -0
  49. package/dist-cjs/predicate/index.js +6 -0
  50. package/dist-cjs/record/decode.d.ts +79 -0
  51. package/dist-cjs/record/decode.js +398 -0
  52. package/dist-cjs/record/freshness.d.ts +17 -0
  53. package/dist-cjs/record/freshness.js +55 -0
  54. package/dist-cjs/record/index.d.ts +21 -0
  55. package/dist-cjs/record/index.js +166 -0
  56. package/dist-cjs/record/movements.d.ts +20 -0
  57. package/dist-cjs/record/movements.js +192 -0
  58. package/dist-cjs/record/rpc.d.ts +22 -0
  59. package/dist-cjs/record/rpc.js +74 -0
  60. package/dist-cjs/record/validate.d.ts +22 -0
  61. package/dist-cjs/record/validate.js +63 -0
  62. package/dist-cjs/registry/identify.d.ts +11 -0
  63. package/dist-cjs/registry/identify.js +87 -0
  64. package/dist-cjs/registry/index.d.ts +3 -0
  65. package/dist-cjs/registry/index.js +15 -0
  66. package/dist-cjs/registry/known-addresses.d.ts +16 -0
  67. package/dist-cjs/registry/known-addresses.js +53 -0
  68. package/dist-cjs/registry/protocols.d.ts +38 -0
  69. package/dist-cjs/registry/protocols.js +153 -0
  70. package/dist-cjs/review-card/builder.d.ts +14 -0
  71. package/dist-cjs/review-card/builder.js +264 -0
  72. package/dist-cjs/review-card/conflict.d.ts +40 -0
  73. package/dist-cjs/review-card/conflict.js +114 -0
  74. package/dist-cjs/review-card/cross-check.d.ts +11 -0
  75. package/dist-cjs/review-card/cross-check.js +151 -0
  76. package/dist-cjs/review-card/index.d.ts +3 -0
  77. package/dist-cjs/review-card/index.js +10 -0
  78. package/dist-cjs/run/index.d.ts +31 -0
  79. package/dist-cjs/run/index.js +178 -0
  80. package/dist-cjs/run/schemas.d.ts +2003 -0
  81. package/dist-cjs/run/schemas.js +218 -0
  82. package/dist-cjs/seams/index.d.ts +1 -0
  83. package/dist-cjs/seams/index.js +3 -0
  84. package/dist-cjs/seams/types.d.ts +66 -0
  85. package/dist-cjs/seams/types.js +12 -0
  86. package/dist-cjs/synth/compose-from-recording.d.ts +64 -0
  87. package/dist-cjs/synth/compose-from-recording.js +356 -0
  88. package/dist-cjs/synth/deny-cases.d.ts +12 -0
  89. package/dist-cjs/synth/deny-cases.js +380 -0
  90. package/dist-cjs/synth/evaluate.d.ts +39 -0
  91. package/dist-cjs/synth/evaluate.js +428 -0
  92. package/dist-cjs/synth/harness.d.ts +16 -0
  93. package/dist-cjs/synth/harness.js +29 -0
  94. package/dist-cjs/synth/index.d.ts +9 -0
  95. package/dist-cjs/synth/index.js +23 -0
  96. package/dist-cjs/synth/lower.d.ts +23 -0
  97. package/dist-cjs/synth/lower.js +119 -0
  98. package/dist-cjs/synth/minimize.d.ts +4 -0
  99. package/dist-cjs/synth/minimize.js +41 -0
  100. package/dist-cjs/synth/predicate-literals.d.ts +5 -0
  101. package/dist-cjs/synth/predicate-literals.js +28 -0
  102. package/dist-cjs/synth/scope.d.ts +26 -0
  103. package/dist-cjs/synth/scope.js +81 -0
  104. package/dist-cjs/synth/synthesize-from-mandate.d.ts +5 -0
  105. package/dist-cjs/synth/synthesize-from-mandate.js +37 -0
  106. package/dist-cjs/synth/synthesize-from-recording.d.ts +49 -0
  107. package/dist-cjs/synth/synthesize-from-recording.js +711 -0
  108. package/dist-cjs/types.d.ts +288 -0
  109. package/dist-cjs/types.js +55 -0
  110. package/dist-cjs/verify/envelope.d.ts +15 -0
  111. package/dist-cjs/verify/envelope.js +23 -0
  112. package/dist-cjs/verify/index.d.ts +3 -0
  113. package/dist-cjs/verify/index.js +8 -0
  114. package/dist-cjs/verify/simulate.d.ts +31 -0
  115. package/dist-cjs/verify/simulate.js +261 -0
  116. package/dist-cjs/verify/verify.d.ts +21 -0
  117. package/dist-cjs/verify/verify.js +192 -0
  118. package/package.json +43 -3
  119. package/src/codegen/template.ts +3 -3
  120. package/src/record/decode.ts +21 -10
  121. package/src/run/index.ts +213 -0
  122. package/src/run/schemas.ts +242 -0
  123. package/src/synth/deny-cases.ts +21 -2
  124. package/src/synth/evaluate.ts +2 -2
  125. package/src/synth/synthesize-from-recording.ts +141 -62
  126. package/src/types.ts +19 -0
  127. package/src/verify/simulate.ts +21 -2
  128. package/src/verify/verify.ts +21 -2
@@ -43,6 +43,7 @@ import type { ToolError, ToolResponse } from '../errors.ts'
43
43
  import { encodePredicate } from '../predicate/encode.ts'
44
44
  import {
45
45
  type ContractInvocation,
46
+ MAX_SCVAL_CLONE_DEPTH,
46
47
  type Network,
47
48
  OZ_LIMITS,
48
49
  type PredicateLeaf,
@@ -102,16 +103,86 @@ export interface SynthesizeFromRecordingOptions {
102
103
  interpreter?: InterpreterAdapterOptions
103
104
  }
104
105
 
105
- /** Synthesize a ProposedPolicy from a recorded transaction. */
106
106
  export function synthesizeFromRecording(
107
107
  tx: RecordedTransaction,
108
108
  opts: SynthesizeFromRecordingOptions,
109
109
  ozConfig: OzAdapterConfig
110
+ ): ToolResponse<ProposedPolicy> {
111
+ // Item 3: ToolError try/catch envelope. Any ToolError-shaped throw (object
112
+ // with a string `.code`) inside the body is converted to a structured
113
+ // `{ok:false, error}`; anything else is rethrown so genuine bugs crash
114
+ // instead of being silently swallowed. The envelope must wrap the entire
115
+ // synthesis body so a thrown `encodePredicate` cap error (PREDICATE_TOO_DEEP,
116
+ // TOO_MANY_LEAVES, etc.) or a depth-cap throw from `cloneScVal` surfaces as
117
+ // a structured ToolError rather than a thrown RangeError.
118
+ try {
119
+ return synthesizeFromRecordingInner(tx, opts, ozConfig)
120
+ } catch (e) {
121
+ if (isToolErrorShape(e)) {
122
+ return {
123
+ ok: false,
124
+ error: {
125
+ code: e.code as ToolError['code'],
126
+ message: e.message,
127
+ severity: 'error',
128
+ retryable: false,
129
+ },
130
+ }
131
+ }
132
+ throw e
133
+ }
134
+ }
135
+
136
+ /** True when `e` is the ToolError-shaped throw the internal pipeline uses to
137
+ * signal a structured failure (e.g. `encodePredicate` cap errors, `cloneScVal`
138
+ * depth-cap throws). Detected by a string `code` field — the only contract
139
+ * the body's internal helpers agree on. */
140
+ function isToolErrorShape(e: unknown): e is { code: string; message: string } {
141
+ if (e === null || typeof e !== 'object') return false
142
+ const code = (e as { code?: unknown }).code
143
+ const message = (e as { message?: unknown }).message
144
+ return typeof code === 'string' && typeof message === 'string'
145
+ }
146
+
147
+ /** ToolError-shaped error helper used by the body to surface a structured
148
+ * failure that the envelope converts to `{ok:false, error}`. */
149
+ function throwToolError(code: ToolError['code'], message: string): never {
150
+ const err = new Error(message) as Error & { code: string; severity: string; retryable: boolean }
151
+ err.code = code
152
+ err.severity = 'error'
153
+ err.retryable = false
154
+ throw err
155
+ }
156
+
157
+ /** Synthesize a ProposedPolicy from a recorded transaction. */
158
+ function synthesizeFromRecordingInner(
159
+ tx: RecordedTransaction,
160
+ opts: SynthesizeFromRecordingOptions,
161
+ ozConfig: OzAdapterConfig
110
162
  ): ToolResponse<ProposedPolicy> {
111
163
  // 0. validate inputs (fail closed - never synthesize from garbage).
112
164
  const invalid = validateOptions(opts)
113
165
  if (invalid) return { ok: false, error: invalid }
114
166
 
167
+ // 0a. per-movement amount validation (item 2). Synthesizing from a recording
168
+ // whose `tokenMovements[].amount` does not match the canonical positive
169
+ // decimal integer format is a malformed-input failure, not a runtime
170
+ // one - reject it here so the downstream `BigInt(m.amount)` in
171
+ // `buildPermitContext` cannot throw a SyntaxError past the envelope.
172
+ for (const [i, m] of tx.tokenMovements.entries()) {
173
+ if (!/^[0-9]+$/.test(m.amount)) {
174
+ return {
175
+ ok: false,
176
+ error: {
177
+ code: 'RECORDING_VALIDATION_FAILED',
178
+ message: `tokenMovements[${i}].amount must be a positive decimal integer string, got: ${m.amount}`,
179
+ severity: 'error',
180
+ retryable: false,
181
+ },
182
+ }
183
+ }
184
+ }
185
+
115
186
  // 1. parseConfidence gate.
116
187
  const threshold = opts.confidenceOverride?.threshold ?? tx.parseConfidence.thresholdUsed
117
188
  if (tx.parseConfidence.overall < threshold) {
@@ -154,9 +225,6 @@ export function synthesizeFromRecording(
154
225
  if (!scopeRes.ok) return scopeRes
155
226
  const scope: ScopeDecision = scopeRes.data
156
227
  if (scope.kind !== 'call_contract') {
157
- // A default scope on the recording-path is not currently a covered flow;
158
- // surface SYNTHESIS_ERROR so the orchestrator never silently emits an
159
- // over-broad default-scoped policy.
160
228
  return {
161
229
  ok: false,
162
230
  error: {
@@ -196,17 +264,6 @@ export function synthesizeFromRecording(
196
264
 
197
265
  // 6. Interpreter compile (opt-in; fail-closed when the user routed real
198
266
  // 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
267
  const interpreterOpts = opts.interpreter
211
268
 
212
269
  let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
@@ -220,18 +277,6 @@ export function synthesizeFromRecording(
220
277
  ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
221
278
  }
222
279
 
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
280
  let startingPredicate: PredicateNode | null = null
236
281
  const testSeam = interpreterOpts.__testPredicateNode
237
282
  if (testSeam !== undefined) {
@@ -243,11 +288,6 @@ export function synthesizeFromRecording(
243
288
  try {
244
289
  interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
245
290
  } 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
291
  const code = (e as { code?: ToolError['code'] }).code
252
292
  const allowedCodes: ToolError['code'][] = [
253
293
  'SCOPE_SELF_CALL',
@@ -338,15 +378,8 @@ export function synthesizeFromRecording(
338
378
  }
339
379
  }
340
380
 
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.
381
+ // 6b. Self-verify + minimise.
346
382
  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
383
  return {
351
384
  ok: false,
352
385
  error: {
@@ -370,10 +403,6 @@ export function synthesizeFromRecording(
370
403
  }
371
404
  const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
372
405
 
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
406
  const finalPredicate: PredicateNode =
378
407
  startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
379
408
 
@@ -407,15 +436,13 @@ export function synthesizeFromRecording(
407
436
  }
408
437
 
409
438
  // 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.
439
+ // canonical bytes back onto the PolicyDocument + PolicyRef. The
440
+ // `encodePredicate` helper throws ToolError-shaped errors on cap
441
+ // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
442
+ // envelope converts them to a structured `{ok:false, error}`.
414
443
  const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
415
444
 
416
445
  if (testSeam !== undefined) {
417
- // Test seam: build the PolicyDocument + PolicyRef now (compile() was
418
- // skipped). Shape mirrors the adapter's output exactly.
419
446
  interpreterPolicyDocument = {
420
447
  grammarVersion: 1,
421
448
  installNonce: interpreterOpts.installNonce ?? 1,
@@ -455,17 +482,14 @@ export function synthesizeFromRecording(
455
482
  }
456
483
 
457
484
  // 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.
485
+ // interpreter actually lowered are misleading: OZ really did not lower them,
486
+ // but the interpreter did. Drop those so the user-facing warnings reflect
487
+ // what is still UN-enforced, not what OZ alone could not do.
463
488
  const ozUncovered = interpreterPolicyRef
464
489
  ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
465
490
  : compileRes.uncovered
466
491
 
467
- // 7. Merge into the OZ-shaped ProposedPolicy. Order:
468
- // [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
492
+ // 7. Merge into the OZ-shaped ProposedPolicy.
469
493
  const ozRefs = compileRes.proposed.policyRefs
470
494
  const mergedRefs: ProposedPolicy['policyRefs'] = []
471
495
  if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
@@ -502,6 +526,10 @@ export function synthesizeFromRecording(
502
526
  return { ok: true, data: proposed }
503
527
  }
504
528
 
529
+ // Re-export the throwToolError helper for callers that need to surface a
530
+ // ToolError-shaped throw inside the body (e.g. tests).
531
+ export { throwToolError }
532
+
505
533
  /** OZ-side `uncovered` warning patterns that the interpreter adapter
506
534
  * actually lowers when wired in. When the interpreter adapter succeeds, we
507
535
  * drop matching entries from the OZ uncovered list so the user-facing
@@ -562,14 +590,31 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
562
590
  `interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
563
591
  )
564
592
  }
593
+ // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
594
+ // C.../56-char shape check so a fixture/LLM-seam marker is reported
595
+ // with the specific placeholder error (not a generic "must be a
596
+ // C... contract" message). A real install routes a C... contract
597
+ // address derivable from the on-chain account; any
598
+ // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
599
+ // fixture marker that must never reach the install payload.
600
+ if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
601
+ return synthesisError(
602
+ `interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
603
+ )
604
+ }
565
605
  if (!isContractAddress(sa)) {
566
606
  return synthesisError(
567
607
  `interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
568
608
  )
569
609
  }
570
610
  const nonce = opts.interpreter.installNonce
571
- if (nonce !== undefined && !isPositiveInt(nonce)) {
572
- return synthesisError(`interpreter.installNonce must be a positive integer, got: ${nonce}`)
611
+ // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
612
+ // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
613
+ // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
614
+ if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
615
+ return synthesisError(
616
+ `interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
617
+ )
573
618
  }
574
619
  const op = opts.interpreter.oracleParams
575
620
  if (op) {
@@ -596,6 +641,13 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
596
641
  return null
597
642
  }
598
643
 
644
+ /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
645
+ * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
646
+ * the interpreter-contract strkey; a real install must point at a C...
647
+ * contract address derivable from the on-chain account, never a
648
+ * fixture/LLM-seam marker. */
649
+ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
650
+
599
651
  function isPositiveInt(n: number): boolean {
600
652
  return Number.isInteger(n) && n > 0
601
653
  }
@@ -689,19 +741,46 @@ function buildPermitContext(
689
741
  return ctx
690
742
  }
691
743
 
692
- function cloneScVal(value: { type: string; value: unknown }): ContractInvocation['args'][number] {
744
+ function cloneScVal(
745
+ value: { type: string; value: unknown },
746
+ depth = 0
747
+ ): ContractInvocation['args'][number] {
693
748
  // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
694
749
  // (decoded by the recorder). We clone top-level shells so the harness can
695
- // mutate deny cases without aliasing the recorded call.
750
+ // mutate deny cases without aliasing the recorded call. Recursion is
751
+ // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
752
+ // cannot RangeError the JS stack; the over-depth branch throws a
753
+ // ToolError-shaped error that the `synthesizeFromRecording` envelope
754
+ // (item 3) converts to `{ok:false, error}`.
696
755
  if (value.type === 'vec') {
756
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
757
+ throw cloneDepthError(value)
758
+ }
697
759
  return {
698
760
  type: 'vec',
699
- value: (value.value as ContractInvocation['args'][number][]).map(cloneScVal),
761
+ value: (value.value as ContractInvocation['args'][number][]).map((v) =>
762
+ cloneScVal(v, depth + 1)
763
+ ),
700
764
  }
701
765
  }
702
766
  return { ...value } as ContractInvocation['args'][number]
703
767
  }
704
768
 
769
+ /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
770
+ * The shape (object with string `code`) is what the `synthesizeFromRecording`
771
+ * envelope detects and converts to a structured `{ok:false, error}`. */
772
+ function cloneDepthError(value: { type: string; value: unknown }): never {
773
+ const err = new Error(
774
+ `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
775
+ ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
776
+ err.code = 'SYNTHESIS_ERROR'
777
+ err.severity = 'error'
778
+ err.retryable = false
779
+ // Tag the offending value so failures are diagnosable.
780
+ err.depthContext = value.type
781
+ throw err
782
+ }
783
+
705
784
  /** Walk every `oracle_price` leaf in the predicate and return a price map
706
785
  * whose entries satisfy the bound so the intended call permits. The
707
786
  * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
package/src/types.ts CHANGED
@@ -33,6 +33,25 @@ export const SOROBAN_LIMITS = {
33
33
  secondsPerLedger: 5,
34
34
  } as const
35
35
 
36
+ /** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
37
+ * A real Stellar tx caps nesting well below this; the bound hard-caps a
38
+ * hand-crafted payload so the recorder fails CLOSED (records the over-depth
39
+ * branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
40
+ * Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
41
+ * already enforces; lifted to 30 so legitimate nested args (a path vec
42
+ * inside an order vec inside a market-data vec) decode cleanly while a
43
+ * malformed ~10000-deep payload still trips the cap. */
44
+ export const MAX_SCVAL_DEPTH = 30 as const
45
+
46
+ /** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
47
+ * the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
48
+ * so a hand-crafted nested-vec payload cannot RangeError the JS stack during
49
+ * context building. Over-depth throws a ToolError (caught by the
50
+ * `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
51
+ * the caller gets a structured `{ok:false, error}` instead of a thrown
52
+ * RangeError. */
53
+ export const MAX_SCVAL_CLONE_DEPTH = 30 as const
54
+
36
55
  /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
37
56
  * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
38
57
  * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
@@ -37,6 +37,7 @@ import type {
37
37
  RecordedTransaction,
38
38
  ScVal,
39
39
  } from '../types.ts'
40
+ import { MAX_SCVAL_CLONE_DEPTH } from '../types.ts'
40
41
  import type { SimulationResult } from './envelope.ts'
41
42
 
42
43
  const SIMULATOR_VERSION = 'ts-model-1.0.0'
@@ -227,13 +228,31 @@ function buildPermitContext(
227
228
  return ctx
228
229
  }
229
230
 
230
- function cloneScVal(value: ScVal): ScVal {
231
+ function cloneScVal(value: ScVal, depth = 0): ScVal {
232
+ // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
233
+ // payload cannot RangeError the JS stack during context building. Over-depth
234
+ // throws a ToolError-shaped error that the simulator's existing try/catch
235
+ // converts to a structured `{ok:false, error}` (not a thrown RangeError).
231
236
  if (value.type === 'vec') {
232
- return { type: 'vec', value: value.value.map(cloneScVal) }
237
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
238
+ throw cloneDepthError(value)
239
+ }
240
+ return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) }
233
241
  }
234
242
  return { ...value }
235
243
  }
236
244
 
245
+ function cloneDepthError(value: ScVal): never {
246
+ const err = new Error(
247
+ `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
248
+ ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
249
+ err.code = 'SIMULATION_ERROR'
250
+ err.severity = 'error'
251
+ err.retryable = false
252
+ err.depthContext = value.type
253
+ throw err
254
+ }
255
+
237
256
  function visitOracleLeaves(
238
257
  node: PredicateNode,
239
258
  visit: (asset: string, op: 'eq' | 'lt' | 'lte' | 'gt' | 'gte', bound: bigint) => void
@@ -32,6 +32,7 @@ import type { ToolError, ToolResponse } from '../errors.ts'
32
32
  import type { EvalContext } from '../synth/evaluate.ts'
33
33
  import { minimize } from '../synth/minimize.ts'
34
34
  import type { ContractInvocation, PredicateNode, RecordedTransaction, ScVal } from '../types.ts'
35
+ import { MAX_SCVAL_CLONE_DEPTH } from '../types.ts'
35
36
 
36
37
  /** Options for `verifyPolicy`. Mirrors the orchestrator's permit-context
37
38
  * knobs (expiry; oracle fixture) so verify sees the same shape
@@ -171,13 +172,31 @@ function buildPermitContextForVerify(
171
172
  return ctx
172
173
  }
173
174
 
174
- function cloneScVal(value: ScVal): ScVal {
175
+ function cloneScVal(value: ScVal, depth = 0): ScVal {
176
+ // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
177
+ // payload cannot RangeError the JS stack during context building. Over-depth
178
+ // throws a ToolError-shaped error that the verifier's existing try/catch
179
+ // converts to a structured `{ok:false, error}` (not a thrown RangeError).
175
180
  if (value.type === 'vec') {
176
- return { type: 'vec', value: value.value.map(cloneScVal) }
181
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
182
+ throw cloneDepthError(value)
183
+ }
184
+ return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) }
177
185
  }
178
186
  return { ...value }
179
187
  }
180
188
 
189
+ function cloneDepthError(value: ScVal): never {
190
+ const err = new Error(
191
+ `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
192
+ ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
193
+ err.code = 'VERIFICATION_FAILED'
194
+ err.severity = 'error'
195
+ err.retryable = false
196
+ err.depthContext = value.type
197
+ throw err
198
+ }
199
+
181
200
  function fingerprint(node: PredicateNode): string {
182
201
  return JSON.stringify(node, replacer)
183
202
  }