@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
@@ -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,
@@ -56,7 +57,7 @@ import {
56
57
  type ComposeUserResponses,
57
58
  composeFromRecording,
58
59
  } from './compose-from-recording.ts'
59
- import { generateCases } from './deny-cases.ts'
60
+ import { generateCases, ORIGINAL_DIMENSIONS } from './deny-cases.ts'
60
61
  import { type EvalContext, evaluate } from './evaluate.ts'
61
62
  import { runHarness } from './harness.ts'
62
63
  import { lower } from './lower.ts'
@@ -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) {
@@ -127,6 +198,33 @@ export function synthesizeFromRecording(
127
198
  }
128
199
  }
129
200
 
201
+ // 1a. Zero-invocation refusal (item 1). A recording with zero contract
202
+ // invocations cleared the parseConfidence gate legitimately (the
203
+ // `denom === 0` short-circuit pins overall to 1.0 for that case), but a
204
+ // policy must scope to an authorized contract call. Refuse before any
205
+ // lower/scope work so the failure is specific and actionable. The
206
+ // recorder's silence is also made visible via `parseConfidence.noInvocations`
207
+ // so consumers can pattern-match without inferring from `invocations: []`
208
+ // next to `overall: 1.0`. The message does NOT ask for an ABI - the
209
+ // failure mode is the recording shape, not decoding coverage.
210
+ const hasNoInvocations = tx.parseConfidence.noInvocations === true || tx.invocations.length === 0
211
+ if (hasNoInvocations) {
212
+ return {
213
+ ok: false,
214
+ error: {
215
+ code: 'SYNTHESIS_ERROR',
216
+ message:
217
+ '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).',
218
+ severity: 'error',
219
+ retryable: false,
220
+ details: {
221
+ invocations: tx.invocations.length,
222
+ noInvocationsMarker: tx.parseConfidence.noInvocations === true,
223
+ },
224
+ },
225
+ }
226
+ }
227
+
130
228
  // Bound the recording size fail-closed (defense-in-depth for direct callers;
131
229
  // the MCP schema caps this too).
132
230
  if (tx.invocations.length > SOROBAN_LIMITS.maxInvocations) {
@@ -154,9 +252,6 @@ export function synthesizeFromRecording(
154
252
  if (!scopeRes.ok) return scopeRes
155
253
  const scope: ScopeDecision = scopeRes.data
156
254
  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
255
  return {
161
256
  ok: false,
162
257
  error: {
@@ -196,17 +291,6 @@ export function synthesizeFromRecording(
196
291
 
197
292
  // 6. Interpreter compile (opt-in; fail-closed when the user routed real
198
293
  // 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
294
  const interpreterOpts = opts.interpreter
211
295
 
212
296
  let interpreterPolicyDocument: ProposedPolicy['policyDocuments'][number] | null = null
@@ -220,18 +304,6 @@ export function synthesizeFromRecording(
220
304
  ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
221
305
  }
222
306
 
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
307
  let startingPredicate: PredicateNode | null = null
236
308
  const testSeam = interpreterOpts.__testPredicateNode
237
309
  if (testSeam !== undefined) {
@@ -243,11 +315,6 @@ export function synthesizeFromRecording(
243
315
  try {
244
316
  interpreterRes = interpreterAdapter.compile(composed.interpreterIr)
245
317
  } 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
318
  const code = (e as { code?: ToolError['code'] }).code
252
319
  const allowedCodes: ToolError['code'][] = [
253
320
  'SCOPE_SELF_CALL',
@@ -338,15 +405,8 @@ export function synthesizeFromRecording(
338
405
  }
339
406
  }
340
407
 
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.
408
+ // 6b. Self-verify + minimise.
346
409
  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
410
  return {
351
411
  ok: false,
352
412
  error: {
@@ -370,14 +430,12 @@ export function synthesizeFromRecording(
370
430
  }
371
431
  const permitCtx = buildPermitContext(tx, scope, topLevel, opts.userResponses, startingPredicate)
372
432
 
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
433
  const finalPredicate: PredicateNode =
378
- startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate
434
+ startingPredicate.op === 'and'
435
+ ? minimize(startingPredicate, permitCtx, ORIGINAL_DIMENSIONS)
436
+ : startingPredicate
379
437
 
380
- const harnessCases = generateCases(finalPredicate, permitCtx)
438
+ const harnessCases = generateCases(finalPredicate, permitCtx, ORIGINAL_DIMENSIONS)
381
439
  const harnessResult = runHarness(finalPredicate, harnessCases)
382
440
  if (!harnessResult.ok) {
383
441
  return {
@@ -407,15 +465,13 @@ export function synthesizeFromRecording(
407
465
  }
408
466
 
409
467
  // 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.
468
+ // canonical bytes back onto the PolicyDocument + PolicyRef. The
469
+ // `encodePredicate` helper throws ToolError-shaped errors on cap
470
+ // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
471
+ // envelope converts them to a structured `{ok:false, error}`.
414
472
  const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate)
415
473
 
416
474
  if (testSeam !== undefined) {
417
- // Test seam: build the PolicyDocument + PolicyRef now (compile() was
418
- // skipped). Shape mirrors the adapter's output exactly.
419
475
  interpreterPolicyDocument = {
420
476
  grammarVersion: 1,
421
477
  installNonce: interpreterOpts.installNonce ?? 1,
@@ -455,17 +511,14 @@ export function synthesizeFromRecording(
455
511
  }
456
512
 
457
513
  // 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.
514
+ // interpreter actually lowered are misleading: OZ really did not lower them,
515
+ // but the interpreter did. Drop those so the user-facing warnings reflect
516
+ // what is still UN-enforced, not what OZ alone could not do.
463
517
  const ozUncovered = interpreterPolicyRef
464
518
  ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
465
519
  : compileRes.uncovered
466
520
 
467
- // 7. Merge into the OZ-shaped ProposedPolicy. Order:
468
- // [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
521
+ // 7. Merge into the OZ-shaped ProposedPolicy.
469
522
  const ozRefs = compileRes.proposed.policyRefs
470
523
  const mergedRefs: ProposedPolicy['policyRefs'] = []
471
524
  if (interpreterPolicyRef) mergedRefs.push(interpreterPolicyRef)
@@ -488,12 +541,26 @@ export function synthesizeFromRecording(
488
541
  policies: mergedRefs,
489
542
  }
490
543
 
544
+ // When nothing installable was synthesised (no interpreter doc AND no OZ
545
+ // policy refs), an empty `policies` array reads as "no restrictions" rather
546
+ // than "I synthesised nothing". Surface that explicitly so the empty result
547
+ // is never mistaken for a permissive policy - the context rule still exists,
548
+ // but it constrains nothing. (Kept as `{ok:true}` so the documented
549
+ // Path-A/Path-B demo behaviour is preserved - see F3.)
550
+ const zeroPolicyWarning =
551
+ mergedRefs.length === 0 && !interpreterPolicyDocument
552
+ ? [
553
+ '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.',
554
+ ]
555
+ : []
556
+
491
557
  const proposed: ProposedPolicy = {
492
558
  contextRule: mergedContextRule,
493
559
  policyDocuments: interpreterPolicyDocument ? [interpreterPolicyDocument] : [],
494
560
  policyRefs: mergedRefs,
495
561
  parseConfidence: { ...tx.parseConfidence },
496
562
  warnings: [
563
+ ...zeroPolicyWarning,
497
564
  ...ozUncovered.map((u) => `${UNCOVERED_PREFIX}${u}`),
498
565
  ...composed.warnings.map((w) => `${UNCOVERED_PREFIX}${w}`),
499
566
  ],
@@ -502,6 +569,10 @@ export function synthesizeFromRecording(
502
569
  return { ok: true, data: proposed }
503
570
  }
504
571
 
572
+ // Re-export the throwToolError helper for callers that need to surface a
573
+ // ToolError-shaped throw inside the body (e.g. tests).
574
+ export { throwToolError }
575
+
505
576
  /** OZ-side `uncovered` warning patterns that the interpreter adapter
506
577
  * actually lowers when wired in. When the interpreter adapter succeeds, we
507
578
  * drop matching entries from the OZ uncovered list so the user-facing
@@ -551,7 +622,7 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
551
622
  }
552
623
  if (ur.limitAmount !== undefined && !isPositiveI128(ur.limitAmount)) {
553
624
  return synthesisError(
554
- `limitAmount must be a positive i128 decimal string, got: ${ur.limitAmount}`
625
+ `limitAmount must be a positive i128 decimal string within [1, ${I128_MAX}] (2^127-1), got: ${ur.limitAmount}`
555
626
  )
556
627
  }
557
628
  }
@@ -562,14 +633,31 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
562
633
  `interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`
563
634
  )
564
635
  }
636
+ // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
637
+ // C.../56-char shape check so a fixture/LLM-seam marker is reported
638
+ // with the specific placeholder error (not a generic "must be a
639
+ // C... contract" message). A real install routes a C... contract
640
+ // address derivable from the on-chain account; any
641
+ // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
642
+ // fixture marker that must never reach the install payload.
643
+ if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
644
+ return synthesisError(
645
+ `interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`
646
+ )
647
+ }
565
648
  if (!isContractAddress(sa)) {
566
649
  return synthesisError(
567
650
  `interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`
568
651
  )
569
652
  }
570
653
  const nonce = opts.interpreter.installNonce
571
- if (nonce !== undefined && !isPositiveInt(nonce)) {
572
- return synthesisError(`interpreter.installNonce must be a positive integer, got: ${nonce}`)
654
+ // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
655
+ // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
656
+ // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
657
+ if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
658
+ return synthesisError(
659
+ `interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`
660
+ )
573
661
  }
574
662
  const op = opts.interpreter.oracleParams
575
663
  if (op) {
@@ -596,15 +684,32 @@ function validateOptions(opts: SynthesizeFromRecordingOptions): ToolError | null
596
684
  return null
597
685
  }
598
686
 
687
+ /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
688
+ * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
689
+ * the interpreter-contract strkey; a real install must point at a C...
690
+ * contract address derivable from the on-chain account, never a
691
+ * fixture/LLM-seam marker. */
692
+ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i
693
+
694
+ /** Maximum value a signed i128 can hold (2^127-1). A limitAmount above this
695
+ * cannot be represented on-chain, so reject it at the synthesis boundary
696
+ * (fail-closed) instead of passing it through as an over-broad spending_limit.
697
+ * Mirrors the SOROBAN_LIMITS.u32Max bound the ledger-sequence fields enforce. */
698
+ const I128_MAX = 2n ** 127n - 1n
699
+
599
700
  function isPositiveInt(n: number): boolean {
600
701
  return Number.isInteger(n) && n > 0
601
702
  }
602
703
 
603
- /** True when `s` is a canonical positive decimal integer (i128-safe via BigInt). */
704
+ /** True when `s` is a canonical positive decimal integer inside the signed-i128
705
+ * range [1, 2^127-1]. A value above the i128 ceiling is rejected (fail-closed):
706
+ * it cannot be installed on-chain, and accepting it would emit a spending_limit
707
+ * with an effectively unbounded cap. */
604
708
  function isPositiveI128(s: string): boolean {
605
709
  if (!/^[0-9]+$/.test(s)) return false
606
710
  try {
607
- return BigInt(s) > 0n
711
+ const v = BigInt(s)
712
+ return v > 0n && v <= I128_MAX
608
713
  } catch {
609
714
  return false
610
715
  }
@@ -689,19 +794,46 @@ function buildPermitContext(
689
794
  return ctx
690
795
  }
691
796
 
692
- function cloneScVal(value: { type: string; value: unknown }): ContractInvocation['args'][number] {
797
+ function cloneScVal(
798
+ value: { type: string; value: unknown },
799
+ depth = 0
800
+ ): ContractInvocation['args'][number] {
693
801
  // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
694
802
  // (decoded by the recorder). We clone top-level shells so the harness can
695
- // mutate deny cases without aliasing the recorded call.
803
+ // mutate deny cases without aliasing the recorded call. Recursion is
804
+ // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
805
+ // cannot RangeError the JS stack; the over-depth branch throws a
806
+ // ToolError-shaped error that the `synthesizeFromRecording` envelope
807
+ // (item 3) converts to `{ok:false, error}`.
696
808
  if (value.type === 'vec') {
809
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
810
+ throw cloneDepthError(value)
811
+ }
697
812
  return {
698
813
  type: 'vec',
699
- value: (value.value as ContractInvocation['args'][number][]).map(cloneScVal),
814
+ value: (value.value as ContractInvocation['args'][number][]).map((v) =>
815
+ cloneScVal(v, depth + 1)
816
+ ),
700
817
  }
701
818
  }
702
819
  return { ...value } as ContractInvocation['args'][number]
703
820
  }
704
821
 
822
+ /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
823
+ * The shape (object with string `code`) is what the `synthesizeFromRecording`
824
+ * envelope detects and converts to a structured `{ok:false, error}`. */
825
+ function cloneDepthError(value: { type: string; value: unknown }): never {
826
+ const err = new Error(
827
+ `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
828
+ ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
829
+ err.code = 'SYNTHESIS_ERROR'
830
+ err.severity = 'error'
831
+ err.retryable = false
832
+ // Tag the offending value so failures are diagnosable.
833
+ err.depthContext = value.type
834
+ throw err
835
+ }
836
+
705
837
  /** Walk every `oracle_price` leaf in the predicate and return a price map
706
838
  * whose entries satisfy the bound so the intended call permits. The
707
839
  * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
package/src/types.ts CHANGED
@@ -11,6 +11,11 @@ export type ScVal =
11
11
  | { type: 'symbol'; value: string }
12
12
  | { type: 'vec'; value: ScVal[] }
13
13
  | { type: 'bytes'; value: string }
14
+ /** Map<Symbol, ScVal> carried forward for SAC/SEP-41 transfer event data,
15
+ * which uses a Map shape (key "amount" -> I128) on Stellar mainnet. The
16
+ * entry key is captured as its symbol/form string so readAmount can route
17
+ * to the conventional field name without re-decoding the full XDR. */
18
+ | { type: 'map'; value: Array<{ key: string; val: ScVal }> }
14
19
  | { type: 'other'; value: string }
15
20
 
16
21
  /** OZ Accounts framework hard limits (verified against canonical OZ source). */
@@ -33,6 +38,25 @@ export const SOROBAN_LIMITS = {
33
38
  secondsPerLedger: 5,
34
39
  } as const
35
40
 
41
+ /** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
42
+ * A real Stellar tx caps nesting well below this; the bound hard-caps a
43
+ * hand-crafted payload so the recorder fails CLOSED (records the over-depth
44
+ * branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
45
+ * Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
46
+ * already enforces; lifted to 30 so legitimate nested args (a path vec
47
+ * inside an order vec inside a market-data vec) decode cleanly while a
48
+ * malformed ~10000-deep payload still trips the cap. */
49
+ export const MAX_SCVAL_DEPTH = 30 as const
50
+
51
+ /** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
52
+ * the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
53
+ * so a hand-crafted nested-vec payload cannot RangeError the JS stack during
54
+ * context building. Over-depth throws a ToolError (caught by the
55
+ * `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
56
+ * the caller gets a structured `{ok:false, error}` instead of a thrown
57
+ * RangeError. */
58
+ export const MAX_SCVAL_CLONE_DEPTH = 30 as const
59
+
36
60
  /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
37
61
  * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
38
62
  * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
@@ -222,7 +246,15 @@ export type AmbiguityCode =
222
246
 
223
247
  /** Structured recording freshness. `overall` is the gate threshold (default 1.0); the
224
248
  * breakdown is the diagnostic the user / agent sees when the gate fires. The
225
- * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`. */
249
+ * computation rule is pinned: `overall = 1 - (unknownContracts + opaqueScVals) / total`.
250
+ *
251
+ * `noInvocations` (item 1) is a recorder-side marker set ONLY when the envelope
252
+ * contained zero `invokeContract` host functions (e.g. a `createContract` or
253
+ * `uploadContractWasm` op). The math is unchanged: that case still scores 1.0
254
+ * via the `denom === 0` guard. The marker exists so a consumer does not have
255
+ * to infer the situation from `invocations.length === 0` next to `overall: 1.0`
256
+ * — a downstream synth refuses such recordings with a specific, actionable
257
+ * error rather than silently producing a useless empty scope. */
226
258
  export interface ParseConfidence {
227
259
  overall: number // 0..1
228
260
  knownContracts: string[]
@@ -232,4 +264,8 @@ export interface ParseConfidence {
232
264
  }>
233
265
  opaqueScVals: Array<{ path: string; type: string }>
234
266
  thresholdUsed: number // 1.0 by default; > 0 only via explicit confidence_override
267
+ /** True iff the recorder decoded zero contract invocations. Absent on
268
+ * recordings that pre-date this field; consumers should treat `undefined`
269
+ * as "no marker" and rely on `invocations.length` as the fallback. */
270
+ noInvocations?: true
235
271
  }
@@ -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
  }