@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
@@ -0,0 +1,215 @@
1
+ // packages/policy-synth/src/run/schemas.ts
2
+ //
3
+ // Zod schemas mirroring the policy-synth core domain types. These are the
4
+ // public input / output shapes exposed over MCP and the CLI. They are kept
5
+ // hand-written (rather than derived) because the MCP SDK needs a runtime
6
+ // Zod object at the transport boundary; a drift test asserts they stay in
7
+ // step with the TS source of truth.
8
+ //
9
+ // i128 amounts and other large integers are carried as base-10 decimal strings
10
+ // end-to-end (no JS number coercion). Networks are pinned to the same closed
11
+ // set the core defines. The discriminated union on `source` exposes BOTH
12
+ // synthesize_policy front-ends through a single tool input.
13
+ //
14
+ // This module is the SINGLE source of truth for these shapes. The MCP package
15
+ // imports them here so its tool-shape bindings stay in step; the CLI imports
16
+ // them here so it can build the same args envelope the MCP transport builds.
17
+ import { z } from 'zod';
18
+ /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
19
+ * installed on-chain, so reject it at the boundary (fail-closed). */
20
+ const U32_MAX = 4294967295;
21
+ /** Upper bound on top-level invocations in a recorded transaction. A real
22
+ * Stellar tx caps operations well below this; the bound stops a hand-crafted
23
+ * payload from turning one request into an unbounded synthesis (DoS). */
24
+ const MAX_INVOCATIONS = 512;
25
+ export const NetworkSchema = z.enum(['mainnet', 'testnet']);
26
+ /** ScVal subset - normalised subset the synth consumes. Mirrors
27
+ * `ScVal` in packages/policy-synth/src/types.ts. */
28
+ export const ScValSchema = z.lazy(() => z.union([
29
+ z.object({ type: z.literal('address'), value: z.string() }),
30
+ // i128 is SIGNED: real events carry negatives (e.g. a fee-adjustment/refund),
31
+ // so the recorder's own output must round-trip through this schema. u64/u32
32
+ // are unsigned and stay non-negative.
33
+ z.object({ type: z.literal('i128'), value: z.string().regex(/^-?[0-9]+$/) }),
34
+ z.object({ type: z.literal('u64'), value: z.string().regex(/^[0-9]+$/) }),
35
+ z.object({ type: z.literal('u32'), value: z.string().regex(/^[0-9]+$/) }),
36
+ z.object({ type: z.literal('symbol'), value: z.string() }),
37
+ z.object({ type: z.literal('vec'), value: z.array(ScValSchema) }),
38
+ z.object({ type: z.literal('bytes'), value: z.string() }),
39
+ z.object({ type: z.literal('other'), value: z.string() }),
40
+ ]));
41
+ /** ContractInvocation mirrors the core. Annotated with an explicit
42
+ * `z.ZodType<unknown>` (like ScValSchema above) so the self-referential
43
+ * `subInvocations` field does not trip TS's circular type inference. */
44
+ export const ContractInvocationSchema = z.object({
45
+ contract: z.string(),
46
+ fn: z.string(),
47
+ args: z.array(ScValSchema),
48
+ subInvocations: z.array(z.lazy(() => ContractInvocationSchema)),
49
+ });
50
+ export const TokenMovementSchema = z.object({
51
+ token: z.string(),
52
+ from: z.string(),
53
+ to: z.string(),
54
+ // The recorder reads the amount straight from the signed i128 event value
55
+ // (record/movements.ts readAmount), so a non-standard token that emits a
56
+ // negative transfer/mint/burn amount round-trips as a negative string. Mirror
57
+ // that here; the synth gate, not the wire schema, decides what to do with it.
58
+ amount: z.string().regex(/^-?[0-9]+$/),
59
+ });
60
+ export const OnChainEventSchema = z.object({
61
+ contract: z.string(),
62
+ topics: z.array(z.string()),
63
+ data: ScValSchema,
64
+ });
65
+ export const ParseConfidenceSchema = z.object({
66
+ overall: z.number().min(0).max(1),
67
+ knownContracts: z.array(z.string()),
68
+ unknownContracts: z.array(z.object({
69
+ contract: z.string(),
70
+ reason: z.enum(['no-abi', 'version-mismatch', 'opaque-result']),
71
+ })),
72
+ opaqueScVals: z.array(z.object({ path: z.string(), type: z.string() })),
73
+ thresholdUsed: z.number().min(0).max(1),
74
+ });
75
+ /** RecordedTransaction mirrors the core RecordedTransaction. The output shape
76
+ * is referenced by name in the tool result structured content; we deliberately
77
+ * type it loosely (`z.unknown()`) on the success path so the core remains the
78
+ * single source of truth for the wire payload. */
79
+ export const RecordedTransactionSchema = z
80
+ .object({
81
+ network: NetworkSchema,
82
+ signers: z.array(z.string()),
83
+ invocations: z.array(ContractInvocationSchema).max(MAX_INVOCATIONS),
84
+ tokenMovements: z.array(TokenMovementSchema),
85
+ events: z.array(OnChainEventSchema),
86
+ authEntries: z.array(z.unknown()),
87
+ ledgerSequence: z.number().int().nonnegative(),
88
+ fetchedAt: z.number().int().nonnegative(),
89
+ parseConfidence: ParseConfidenceSchema,
90
+ sourceAccount: z.string(),
91
+ })
92
+ .passthrough();
93
+ /** MandateSpec mirrors the core MandateSpec. The deterministic Mandate
94
+ * front-end needs no parseConfidence; the tool adapter injects the full
95
+ * confidence after synthesis so the orchestrator can compare. */
96
+ export const MandateSpecSchema = z
97
+ .object({
98
+ chain: z.literal('stellar'),
99
+ contract: z.string(),
100
+ method: z.string().optional(),
101
+ spendingLimit: z
102
+ .object({
103
+ token: z.string(),
104
+ limit: z.string().regex(/^[0-9]+$/),
105
+ windowSeconds: z.number().int().positive(),
106
+ })
107
+ .optional(),
108
+ // A threshold of 0 means "0 approvals", which is not a real M-of-N gate.
109
+ approvalThreshold: z.number().int().positive().optional(),
110
+ recipients: z.array(z.string()).optional(),
111
+ expiry: z
112
+ .object({
113
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
114
+ validUntilUnixSeconds: z.number().int().positive().optional(),
115
+ })
116
+ .optional(),
117
+ })
118
+ .passthrough();
119
+ /** ComposeUserResponses mirrors the core. */
120
+ export const ComposeUserResponsesSchema = z
121
+ .object({
122
+ windowSeconds: z.number().int().positive().optional(),
123
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
124
+ limitAmount: z
125
+ .string()
126
+ .regex(/^[0-9]+$/)
127
+ .optional(),
128
+ invocationLimit: z.number().int().positive().optional(),
129
+ })
130
+ .passthrough();
131
+ /** OzAdapterConfig - the per-network OZ built-in instance addresses. */
132
+ export const OzAdapterConfigSchema = z.object({
133
+ network: NetworkSchema,
134
+ instances: z.object({
135
+ spending_limit: z.string(),
136
+ simple_threshold: z.string(),
137
+ weighted_threshold: z.string(),
138
+ }),
139
+ });
140
+ // ===== record_transaction =====
141
+ export const RecordTransactionInputSchema = z
142
+ .object({
143
+ hash: z.string().min(1).optional(),
144
+ xdr: z.string().min(1).optional(),
145
+ network: NetworkSchema,
146
+ confidenceOverride: z.number().min(0).max(1).optional(),
147
+ })
148
+ .refine((v) => !(v.hash && v.xdr), {
149
+ message: 'provide exactly one of `hash` or `xdr`, not both',
150
+ })
151
+ .refine((v) => Boolean(v.hash) || Boolean(v.xdr), {
152
+ message: 'one of `hash` or `xdr` is required',
153
+ });
154
+ // ===== synthesize_policy =====
155
+ //
156
+ // Discriminated union on `source` exposes BOTH front-ends through ONE tool.
157
+ // - `source: 'mandate'` -> calls synthesizeFromMandate
158
+ // - `source: 'recording'` -> calls synthesizeFromRecording
159
+ export const SynthesizePolicyMandateInputSchema = z.object({
160
+ source: z.literal('mandate'),
161
+ mandate: MandateSpecSchema,
162
+ ozConfig: OzAdapterConfigSchema.optional(),
163
+ });
164
+ /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
165
+ * express (per-method scoping, invocation-count windows, oracle bounds, exact
166
+ * hop paths) lower to a real interpreter predicate document instead of being
167
+ * surfaced as warnings. The core deep-validates `smartAccountAddress` (a C...
168
+ * contract, not the recording's G... source) and the tighten-only oracle
169
+ * bounds; the schema stays light so the core owns the friendly ToolErrors. */
170
+ export const InterpreterOptionsSchema = z.object({
171
+ smartAccountAddress: z.string(),
172
+ installNonce: z.number().int().positive().optional(),
173
+ oracleParams: z
174
+ .object({
175
+ maxStalenessSeconds: z.number().int().positive().optional(),
176
+ maxDeviationBps: z.number().int().positive().optional(),
177
+ })
178
+ .optional(),
179
+ });
180
+ export const SynthesizePolicyRecordingInputSchema = z.object({
181
+ source: z.literal('recording'),
182
+ recordedTx: RecordedTransactionSchema,
183
+ network: NetworkSchema,
184
+ userResponses: ComposeUserResponsesSchema.optional(),
185
+ confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
186
+ interpreter: InterpreterOptionsSchema.optional(),
187
+ ozConfig: OzAdapterConfigSchema.optional(),
188
+ });
189
+ export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
190
+ SynthesizePolicyMandateInputSchema,
191
+ SynthesizePolicyRecordingInputSchema,
192
+ ]);
193
+ // ===== Error envelope (canonical) =====
194
+ //
195
+ // Mirrors ToolError from packages/policy-synth/src/errors.ts. We use a
196
+ // `z.string()` for `code` (not an enum) because the core's ErrorCode union
197
+ // evolves over time; the transport contract only promises a string code the
198
+ // caller can dispatch on. A drift test asserts the canonical codes still
199
+ // pass through unchanged.
200
+ export const ToolErrorSchema = z
201
+ .object({
202
+ code: z.string(),
203
+ message: z.string(),
204
+ severity: z.enum(['info', 'warning', 'error', 'fatal']),
205
+ retryable: z.boolean(),
206
+ remediation: z
207
+ .object({
208
+ toolCall: z.object({ name: z.string(), args: z.record(z.unknown()) }).optional(),
209
+ userQuestion: z.object({ code: z.string(), question: z.string() }).optional(),
210
+ docsUrl: z.string().optional(),
211
+ })
212
+ .optional(),
213
+ details: z.unknown().optional(),
214
+ })
215
+ .passthrough();
@@ -1,3 +1,4 @@
1
+ import { MAX_SCVAL_CLONE_DEPTH } from "../types.js";
1
2
  import { literalNumericBigInt } from "./predicate-literals.js";
2
3
  const ORACLE_CASES = [
3
4
  ['oracle_stale', 'stale'],
@@ -353,9 +354,24 @@ function cloneContext(ctx) {
353
354
  cloned.signerWeights = { ...ctx.signerWeights };
354
355
  return cloned;
355
356
  }
356
- function cloneScVal(value) {
357
+ function cloneScVal(value, depth = 0) {
358
+ // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
359
+ // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
360
+ // throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
361
+ // (item 3) converts to a structured `{ok:false, error}`.
357
362
  if (value.type === 'vec') {
358
- return { type: 'vec', value: value.value.map(cloneScVal) };
363
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
364
+ throw cloneDepthError(value);
365
+ }
366
+ return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) };
359
367
  }
360
368
  return { ...value };
361
369
  }
370
+ function cloneDepthError(value) {
371
+ const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
372
+ err.code = 'SYNTHESIS_ERROR';
373
+ err.severity = 'error';
374
+ err.retryable = false;
375
+ err.depthContext = value.type;
376
+ throw err;
377
+ }
@@ -200,7 +200,7 @@ function evalCompare(op, left, right, ctx) {
200
200
  function evalArgEq(op, actual, right, ctx) {
201
201
  // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
202
202
  if (op === 'eq' && right.kind === 'literal_vec') {
203
- if (!actual || actual.type !== 'vec')
203
+ if (actual?.type !== 'vec')
204
204
  return { permit: false, reason: 'ARG_MISMATCH' };
205
205
  return compareVecExact(actual.value, right.elements, ctx)
206
206
  ? { permit: true }
@@ -218,7 +218,7 @@ function evalArgEq(op, actual, right, ctx) {
218
218
  }
219
219
  // eq(call_arg[i], literal_i128) -> BigInt compare
220
220
  if (op === 'eq' && right.kind === 'literal_i128') {
221
- if (!actual || actual.type !== 'i128')
221
+ if (actual?.type !== 'i128')
222
222
  return { permit: false, reason: 'ARG_MISMATCH' };
223
223
  return BigInt(actual.value) === BigInt(right.value)
224
224
  ? { permit: true }
@@ -1,5 +1,5 @@
1
1
  import type { OzAdapterConfig } from '../adapters/oz/adapter.ts';
2
- import type { ToolResponse } from '../errors.ts';
2
+ import type { ToolError, ToolResponse } from '../errors.ts';
3
3
  import { type Network, type PredicateNode, type ProposedPolicy, type RecordedTransaction } from '../types.ts';
4
4
  import { type ComposeUserResponses } from './compose-from-recording.ts';
5
5
  /** Per-policy interpreter adapter config the caller opts into. Absent ->
@@ -42,5 +42,8 @@ export interface SynthesizeFromRecordingOptions {
42
42
  };
43
43
  interpreter?: InterpreterAdapterOptions;
44
44
  }
45
- /** Synthesize a ProposedPolicy from a recorded transaction. */
46
45
  export declare function synthesizeFromRecording(tx: RecordedTransaction, opts: SynthesizeFromRecordingOptions, ozConfig: OzAdapterConfig): ToolResponse<ProposedPolicy>;
46
+ /** ToolError-shaped error helper used by the body to surface a structured
47
+ * failure that the envelope converts to `{ok:false, error}`. */
48
+ declare function throwToolError(code: ToolError['code'], message: string): never;
49
+ export { throwToolError };
@@ -33,7 +33,7 @@
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
38
  import { generateCases } from "./deny-cases.js";
39
39
  import { evaluate } from "./evaluate.js";
@@ -42,12 +42,76 @@ 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) {
@@ -88,9 +152,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
88
152
  return scopeRes;
89
153
  const scope = scopeRes.data;
90
154
  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
155
  return {
95
156
  ok: false,
96
157
  error: {
@@ -126,17 +187,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
126
187
  }
127
188
  // 6. Interpreter compile (opt-in; fail-closed when the user routed real
128
189
  // 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
190
  const interpreterOpts = opts.interpreter;
141
191
  let interpreterPolicyDocument = null;
142
192
  let interpreterPolicyRef = null;
@@ -147,18 +197,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
147
197
  smartAccountAddress: interpreterOpts.smartAccountAddress,
148
198
  ...(interpreterOpts.oracleParams ? { oracleParams: interpreterOpts.oracleParams } : {}),
149
199
  };
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
200
  let startingPredicate = null;
163
201
  const testSeam = interpreterOpts.__testPredicateNode;
164
202
  if (testSeam !== undefined) {
@@ -171,11 +209,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
171
209
  interpreterRes = interpreterAdapter.compile(composed.interpreterIr);
172
210
  }
173
211
  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
212
  const code = e.code;
180
213
  const allowedCodes = [
181
214
  'SCOPE_SELF_CALL',
@@ -260,15 +293,8 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
260
293
  },
261
294
  };
262
295
  }
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.
296
+ // 6b. Self-verify + minimise.
268
297
  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
298
  return {
273
299
  ok: false,
274
300
  error: {
@@ -291,10 +317,6 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
291
317
  };
292
318
  }
293
319
  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
320
  const finalPredicate = startingPredicate.op === 'and' ? minimize(startingPredicate, permitCtx) : startingPredicate;
299
321
  const harnessCases = generateCases(finalPredicate, permitCtx);
300
322
  const harnessResult = runHarness(finalPredicate, harnessCases);
@@ -324,14 +346,12 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
324
346
  };
325
347
  }
326
348
  // 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.
349
+ // canonical bytes back onto the PolicyDocument + PolicyRef. The
350
+ // `encodePredicate` helper throws ToolError-shaped errors on cap
351
+ // breaches (PREDICATE_TOO_DEEP, TOO_MANY_LEAVES, etc.) - the outer
352
+ // envelope converts them to a structured `{ok:false, error}`.
331
353
  const { encodedPredicate, predicateHash } = encodePredicate(finalPredicate);
332
354
  if (testSeam !== undefined) {
333
- // Test seam: build the PolicyDocument + PolicyRef now (compile() was
334
- // skipped). Shape mirrors the adapter's output exactly.
335
355
  interpreterPolicyDocument = {
336
356
  grammarVersion: 1,
337
357
  installNonce: interpreterOpts.installNonce ?? 1,
@@ -371,16 +391,13 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
371
391
  }
372
392
  }
373
393
  // 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.
394
+ // interpreter actually lowered are misleading: OZ really did not lower them,
395
+ // but the interpreter did. Drop those so the user-facing warnings reflect
396
+ // what is still UN-enforced, not what OZ alone could not do.
379
397
  const ozUncovered = interpreterPolicyRef
380
398
  ? compileRes.uncovered.filter((u) => !INTERPRETER_COVERED_OZ_PATTERN.test(u))
381
399
  : compileRes.uncovered;
382
- // 7. Merge into the OZ-shaped ProposedPolicy. Order:
383
- // [interpreterRef?, ...oz_builtinRefs]; cap = OZ_LIMITS.maxPoliciesPerRule.
400
+ // 7. Merge into the OZ-shaped ProposedPolicy.
384
401
  const ozRefs = compileRes.proposed.policyRefs;
385
402
  const mergedRefs = [];
386
403
  if (interpreterPolicyRef)
@@ -415,6 +432,9 @@ export function synthesizeFromRecording(tx, opts, ozConfig) {
415
432
  };
416
433
  return { ok: true, data: proposed };
417
434
  }
435
+ // Re-export the throwToolError helper for callers that need to surface a
436
+ // ToolError-shaped throw inside the body (e.g. tests).
437
+ export { throwToolError };
418
438
  /** OZ-side `uncovered` warning patterns that the interpreter adapter
419
439
  * actually lowers when wired in. When the interpreter adapter succeeds, we
420
440
  * drop matching entries from the OZ uncovered list so the user-facing
@@ -461,12 +481,25 @@ function validateOptions(opts) {
461
481
  if (typeof sa !== 'string' || sa.length === 0) {
462
482
  return synthesisError(`interpreter.smartAccountAddress must be a non-empty string, got: ${String(sa)}`);
463
483
  }
484
+ // Blocklist placeholder / stub prefixes (item 5). Runs BEFORE the
485
+ // C.../56-char shape check so a fixture/LLM-seam marker is reported
486
+ // with the specific placeholder error (not a generic "must be a
487
+ // C... contract" message). A real install routes a C... contract
488
+ // address derivable from the on-chain account; any
489
+ // 'VERIFY-*' / 'PLACEHOLDER-*' / 'TODO-*' prefix is an LLM seam /
490
+ // fixture marker that must never reach the install payload.
491
+ if (PLACEHOLDER_SMART_ACCOUNT_PREFIX.test(sa)) {
492
+ return synthesisError(`interpreter.smartAccountAddress must not be a placeholder/stub address (matches /${PLACEHOLDER_SMART_ACCOUNT_PREFIX.source}/), got: ${sa}`);
493
+ }
464
494
  if (!isContractAddress(sa)) {
465
495
  return synthesisError(`interpreter.smartAccountAddress must be a C... Stellar contract address (the on-chain policy-bound account, not the G... source account), got: ${sa}`);
466
496
  }
467
497
  const nonce = opts.interpreter.installNonce;
468
- if (nonce !== undefined && !isPositiveInt(nonce)) {
469
- return synthesisError(`interpreter.installNonce must be a positive integer, got: ${nonce}`);
498
+ // Item 4: installNonce must fit u32 (the on-chain per-rule nonce is a
499
+ // u32; values above SOROBAN_LIMITS.u32Max cannot be installed). Mirrors
500
+ // the validUntilLedger SOROBAN_LIMITS.u32Max check above.
501
+ if (nonce !== undefined && (!isPositiveInt(nonce) || nonce > SOROBAN_LIMITS.u32Max)) {
502
+ return synthesisError(`interpreter.installNonce must be a positive u32 integer (<= ${SOROBAN_LIMITS.u32Max}), got: ${nonce}`);
470
503
  }
471
504
  const op = opts.interpreter.oracleParams;
472
505
  if (op) {
@@ -484,6 +517,12 @@ function validateOptions(opts) {
484
517
  }
485
518
  return null;
486
519
  }
520
+ /** Placeholder/stub smart-account prefixes (item 5). Mirrors the
521
+ * `PLACEHOLDER_INTERPRETER_ADDRESS` marker the interpreter adapter uses for
522
+ * the interpreter-contract strkey; a real install must point at a C...
523
+ * contract address derivable from the on-chain account, never a
524
+ * fixture/LLM-seam marker. */
525
+ const PLACEHOLDER_SMART_ACCOUNT_PREFIX = /^(VERIFY-|PLACEHOLDER-|TODO-)/i;
487
526
  function isPositiveInt(n) {
488
527
  return Number.isInteger(n) && n > 0;
489
528
  }
@@ -561,18 +600,37 @@ function buildPermitContext(tx, scope, topLevel, userResponses, predicate) {
561
600
  }
562
601
  return ctx;
563
602
  }
564
- function cloneScVal(value) {
603
+ function cloneScVal(value, depth = 0) {
565
604
  // EvalContext.args is an ScVal[]; the recorded args are already ScVal-shaped
566
605
  // (decoded by the recorder). We clone top-level shells so the harness can
567
- // mutate deny cases without aliasing the recorded call.
606
+ // mutate deny cases without aliasing the recorded call. Recursion is
607
+ // bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec payload
608
+ // cannot RangeError the JS stack; the over-depth branch throws a
609
+ // ToolError-shaped error that the `synthesizeFromRecording` envelope
610
+ // (item 3) converts to `{ok:false, error}`.
568
611
  if (value.type === 'vec') {
612
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
613
+ throw cloneDepthError(value);
614
+ }
569
615
  return {
570
616
  type: 'vec',
571
- value: value.value.map(cloneScVal),
617
+ value: value.value.map((v) => cloneScVal(v, depth + 1)),
572
618
  };
573
619
  }
574
620
  return { ...value };
575
621
  }
622
+ /** Throw a ToolError-shaped error when the clone exceeds MAX_SCVAL_CLONE_DEPTH.
623
+ * The shape (object with string `code`) is what the `synthesizeFromRecording`
624
+ * envelope detects and converts to a structured `{ok:false, error}`. */
625
+ function cloneDepthError(value) {
626
+ const err = new Error(`ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`);
627
+ err.code = 'SYNTHESIS_ERROR';
628
+ err.severity = 'error';
629
+ err.retryable = false;
630
+ // Tag the offending value so failures are diagnosable.
631
+ err.depthContext = value.type;
632
+ throw err;
633
+ }
576
634
  /** Walk every `oracle_price` leaf in the predicate and return a price map
577
635
  * whose entries satisfy the bound so the intended call permits. The
578
636
  * timestamp is pinned to `nowSeconds` (the recorded `fetchedAt`) so the
package/dist/types.d.ts CHANGED
@@ -42,6 +42,23 @@ export declare const SOROBAN_LIMITS: {
42
42
  * used to convert a spend window in seconds to OZ `period_ledgers`. */
43
43
  readonly secondsPerLedger: 5;
44
44
  };
45
+ /** Maximum recursion depth for decoding Recursive `ScVal` shapes (vec-in-vec).
46
+ * A real Stellar tx caps nesting well below this; the bound hard-caps a
47
+ * hand-crafted payload so the recorder fails CLOSED (records the over-depth
48
+ * branch as opaque `depth-exceeded`) rather than RangeErroring the JS stack.
49
+ * Mirrors the `MAX_AUTH_TREE_DEPTH = 16` boundary the auth-tree decoder
50
+ * already enforces; lifted to 30 so legitimate nested args (a path vec
51
+ * inside an order vec inside a market-data vec) decode cleanly while a
52
+ * malformed ~10000-deep payload still trips the cap. */
53
+ export declare const MAX_SCVAL_DEPTH: 30;
54
+ /** Maximum recursion depth for cloning nested `ScVal` shapes (vec-in-vec) into
55
+ * the simulator / verify / harness contexts. Mirrors `MAX_SCVAL_DEPTH` above
56
+ * so a hand-crafted nested-vec payload cannot RangeError the JS stack during
57
+ * context building. Over-depth throws a ToolError (caught by the
58
+ * `synthesizeFromRecording` envelope + the simulator/verify boundaries) so
59
+ * the caller gets a structured `{ok:false, error}` instead of a thrown
60
+ * RangeError. */
61
+ export declare const MAX_SCVAL_CLONE_DEPTH: 30;
45
62
  /** Predicate-document caps - enforced fail-closed at install by BOTH the synth (TS)
46
63
  * AND the interpreter (Rust). Single source: a CI test asserts the two cap sets are
47
64
  * byte-identical (TS reads the same JSON manifest the Rust build emits). If they
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