@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
@@ -0,0 +1,250 @@
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
+
18
+ import { z } from 'zod'
19
+ import { isStellarAddress } from '../synth/address.ts'
20
+
21
+ /** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
22
+ * installed on-chain, so reject it at the boundary (fail-closed). */
23
+ const U32_MAX = 4294967295
24
+ /** Upper bound on top-level invocations in a recorded transaction. A real
25
+ * Stellar tx caps operations well below this; the bound stops a hand-crafted
26
+ * payload from turning one request into an unbounded synthesis (DoS). */
27
+ const MAX_INVOCATIONS = 512
28
+
29
+ export const NetworkSchema = z.enum(['mainnet', 'testnet'])
30
+ export type Network = z.infer<typeof NetworkSchema>
31
+
32
+ /** ScVal subset - normalised subset the synth consumes. Mirrors
33
+ * `ScVal` in packages/policy-synth/src/types.ts. */
34
+ export const ScValSchema: z.ZodType<unknown> = z.lazy(() =>
35
+ z.union([
36
+ z.object({ type: z.literal('address'), value: z.string() }),
37
+ // i128 is SIGNED: real events carry negatives (e.g. a fee-adjustment/refund),
38
+ // so the recorder's own output must round-trip through this schema. u64/u32
39
+ // are unsigned and stay non-negative.
40
+ z.object({ type: z.literal('i128'), value: z.string().regex(/^-?[0-9]+$/) }),
41
+ z.object({ type: z.literal('u64'), value: z.string().regex(/^[0-9]+$/) }),
42
+ z.object({ type: z.literal('u32'), value: z.string().regex(/^[0-9]+$/) }),
43
+ z.object({ type: z.literal('symbol'), value: z.string() }),
44
+ z.object({ type: z.literal('vec'), value: z.array(ScValSchema) }),
45
+ z.object({ type: z.literal('bytes'), value: z.string() }),
46
+ z.object({ type: z.literal('other'), value: z.string() }),
47
+ ])
48
+ )
49
+
50
+ /** ContractInvocation mirrors the core. Annotated with an explicit
51
+ * `z.ZodType<unknown>` (like ScValSchema above) so the self-referential
52
+ * `subInvocations` field does not trip TS's circular type inference. */
53
+ export const ContractInvocationSchema: z.ZodType<unknown> = z.object({
54
+ contract: z.string(),
55
+ fn: z.string(),
56
+ args: z.array(ScValSchema),
57
+ subInvocations: z.array(z.lazy(() => ContractInvocationSchema)),
58
+ })
59
+
60
+ export const TokenMovementSchema = z.object({
61
+ token: z.string(),
62
+ from: z.string(),
63
+ to: z.string(),
64
+ // The recorder reads the amount straight from the signed i128 event value
65
+ // (record/movements.ts readAmount), so a non-standard token that emits a
66
+ // negative transfer/mint/burn amount round-trips as a negative string. Mirror
67
+ // that here; the synth gate, not the wire schema, decides what to do with it.
68
+ amount: z.string().regex(/^-?[0-9]+$/),
69
+ })
70
+
71
+ export const OnChainEventSchema = z.object({
72
+ contract: z.string(),
73
+ topics: z.array(z.string()),
74
+ data: ScValSchema,
75
+ })
76
+
77
+ export const ParseConfidenceSchema = z.object({
78
+ overall: z.number().min(0).max(1),
79
+ knownContracts: z.array(z.string()),
80
+ unknownContracts: z.array(
81
+ z.object({
82
+ contract: z.string(),
83
+ reason: z.enum(['no-abi', 'version-mismatch', 'opaque-result']),
84
+ })
85
+ ),
86
+ opaqueScVals: z.array(z.object({ path: z.string(), type: z.string() })),
87
+ thresholdUsed: z.number().min(0).max(1),
88
+ })
89
+
90
+ /** RecordedTransaction mirrors the core RecordedTransaction. The output shape
91
+ * is referenced by name in the tool result structured content; we deliberately
92
+ * type it loosely (`z.unknown()`) on the success path so the core remains the
93
+ * single source of truth for the wire payload. */
94
+ export const RecordedTransactionSchema = z
95
+ .object({
96
+ network: NetworkSchema,
97
+ signers: z.array(z.string()),
98
+ invocations: z.array(ContractInvocationSchema).max(MAX_INVOCATIONS),
99
+ tokenMovements: z.array(TokenMovementSchema),
100
+ events: z.array(OnChainEventSchema),
101
+ authEntries: z.array(z.unknown()),
102
+ ledgerSequence: z.number().int().nonnegative(),
103
+ fetchedAt: z.number().int().nonnegative(),
104
+ parseConfidence: ParseConfidenceSchema,
105
+ sourceAccount: z.string(),
106
+ })
107
+ .passthrough()
108
+
109
+ /** MandateSpec mirrors the core MandateSpec. The deterministic Mandate
110
+ * front-end needs no parseConfidence; the tool adapter injects the full
111
+ * confidence after synthesis so the orchestrator can compare. */
112
+ export const MandateSpecSchema = z
113
+ .object({
114
+ chain: z.literal('stellar'),
115
+ contract: z.string(),
116
+ method: z.string().optional(),
117
+ spendingLimit: z
118
+ .object({
119
+ token: z.string(),
120
+ limit: z.string().regex(/^[0-9]+$/),
121
+ windowSeconds: z.number().int().positive(),
122
+ })
123
+ .optional(),
124
+ // A threshold of 0 means "0 approvals", which is not a real M-of-N gate.
125
+ approvalThreshold: z.number().int().positive().optional(),
126
+ recipients: z.array(z.string()).optional(),
127
+ expiry: z
128
+ .object({
129
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
130
+ validUntilUnixSeconds: z.number().int().positive().optional(),
131
+ })
132
+ .optional(),
133
+ })
134
+ .passthrough()
135
+
136
+ /** ComposeUserResponses mirrors the core. */
137
+ export const ComposeUserResponsesSchema = z
138
+ .object({
139
+ windowSeconds: z.number().int().positive().optional(),
140
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
141
+ limitAmount: z
142
+ .string()
143
+ .regex(/^[0-9]+$/)
144
+ .optional(),
145
+ invocationLimit: z.number().int().positive().optional(),
146
+ // Swap recipient allowlist (SoroSwap call_arg[3]). Each entry must be a
147
+ // Stellar address (G... wallet or C... contract); supplying it REPLACES the
148
+ // default pin to the recorded recipient. Validated with the shared StrKey
149
+ // helper (no hand-rolled regex).
150
+ swapRecipientAllowlist: z
151
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
152
+ .optional(),
153
+ })
154
+ .passthrough()
155
+
156
+ /** OzAdapterConfig - the per-network OZ built-in instance addresses. */
157
+ export const OzAdapterConfigSchema = z.object({
158
+ network: NetworkSchema,
159
+ instances: z.object({
160
+ spending_limit: z.string(),
161
+ simple_threshold: z.string(),
162
+ weighted_threshold: z.string(),
163
+ }),
164
+ })
165
+
166
+ // ===== record_transaction =====
167
+
168
+ export const RecordTransactionInputSchema = z
169
+ .object({
170
+ hash: z.string().min(1).optional(),
171
+ xdr: z.string().min(1).optional(),
172
+ network: NetworkSchema,
173
+ confidenceOverride: z.number().min(0).max(1).optional(),
174
+ })
175
+ .refine((v) => !(v.hash && v.xdr), {
176
+ message: 'provide exactly one of `hash` or `xdr`, not both',
177
+ })
178
+ .refine((v) => Boolean(v.hash) || Boolean(v.xdr), {
179
+ message: 'one of `hash` or `xdr` is required',
180
+ })
181
+ export type RecordTransactionInput = z.infer<typeof RecordTransactionInputSchema>
182
+
183
+ // ===== synthesize_policy =====
184
+ //
185
+ // Discriminated union on `source` exposes BOTH front-ends through ONE tool.
186
+ // - `source: 'mandate'` -> calls synthesizeFromMandate
187
+ // - `source: 'recording'` -> calls synthesizeFromRecording
188
+
189
+ export const SynthesizePolicyMandateInputSchema = z.object({
190
+ source: z.literal('mandate'),
191
+ mandate: MandateSpecSchema,
192
+ ozConfig: OzAdapterConfigSchema.optional(),
193
+ })
194
+
195
+ /** Interpreter opt-in for the recording path. Present -> constraints OZ cannot
196
+ * express (per-method scoping, invocation-count windows, oracle bounds, exact
197
+ * hop paths) lower to a real interpreter predicate document instead of being
198
+ * surfaced as warnings. The core deep-validates `smartAccountAddress` (a C...
199
+ * contract, not the recording's G... source) and the tighten-only oracle
200
+ * bounds; the schema stays light so the core owns the friendly ToolErrors. */
201
+ export const InterpreterOptionsSchema = z.object({
202
+ smartAccountAddress: z.string(),
203
+ installNonce: z.number().int().positive().optional(),
204
+ oracleParams: z
205
+ .object({
206
+ maxStalenessSeconds: z.number().int().positive().optional(),
207
+ maxDeviationBps: z.number().int().positive().optional(),
208
+ })
209
+ .optional(),
210
+ })
211
+
212
+ export const SynthesizePolicyRecordingInputSchema = z.object({
213
+ source: z.literal('recording'),
214
+ recordedTx: RecordedTransactionSchema,
215
+ network: NetworkSchema,
216
+ userResponses: ComposeUserResponsesSchema.optional(),
217
+ confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
218
+ interpreter: InterpreterOptionsSchema.optional(),
219
+ ozConfig: OzAdapterConfigSchema.optional(),
220
+ })
221
+
222
+ export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
223
+ SynthesizePolicyMandateInputSchema,
224
+ SynthesizePolicyRecordingInputSchema,
225
+ ])
226
+ export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>
227
+
228
+ // ===== Error envelope (canonical) =====
229
+ //
230
+ // Mirrors ToolError from packages/policy-synth/src/errors.ts. We use a
231
+ // `z.string()` for `code` (not an enum) because the core's ErrorCode union
232
+ // evolves over time; the transport contract only promises a string code the
233
+ // caller can dispatch on. A drift test asserts the canonical codes still
234
+ // pass through unchanged.
235
+ export const ToolErrorSchema = z
236
+ .object({
237
+ code: z.string(),
238
+ message: z.string(),
239
+ severity: z.enum(['info', 'warning', 'error', 'fatal']),
240
+ retryable: z.boolean(),
241
+ remediation: z
242
+ .object({
243
+ toolCall: z.object({ name: z.string(), args: z.record(z.unknown()) }).optional(),
244
+ userQuestion: z.object({ code: z.string(), question: z.string() }).optional(),
245
+ docsUrl: z.string().optional(),
246
+ })
247
+ .optional(),
248
+ details: z.unknown().optional(),
249
+ })
250
+ .passthrough()
@@ -0,0 +1,14 @@
1
+ // src/synth/address.ts - Stellar address validation shared by the CLI + the
2
+ // run-layer Zod schema.
3
+
4
+ import { StrKey } from '@stellar/stellar-sdk'
5
+
6
+ /** True when `s` is a valid Stellar address strkey - either an Ed25519 public
7
+ * key (`G...`) or a contract address (`C...`). Backed by the SDK's `StrKey`
8
+ * decoder (the same one the recorder uses in `record/decode.ts`); no
9
+ * hand-rolled regex. Used to validate a caller-supplied swap recipient
10
+ * allowlist, whose entries may be either a wallet (`G...`) or a contract
11
+ * (`C...`). */
12
+ export function isStellarAddress(s: string): boolean {
13
+ return StrKey.isValidEd25519PublicKey(s) || StrKey.isValidContract(s)
14
+ }
@@ -81,9 +81,10 @@ export interface ComposeUserResponses {
81
81
  * entries on the same asset emit multiple leaves. */
82
82
  oraclePriceBound?: OraclePriceBound[]
83
83
  /** Recipient allowlist for a swap (call_arg[3] on SoroSwap's
84
- * swap_exact_tokens_for_tokens). Absent -> RECIPIENT_ALLOWLIST_EMPTY
85
- * ambiguity (the caller is prompted; proceeding without an allowlist leaves
86
- * the recipient unconstrained). */
84
+ * swap_exact_tokens_for_tokens). When supplied, it REPLACES the default
85
+ * pin. Absent -> the recipient is pinned to the recorded value (mirroring
86
+ * SEP-41) and RECIPIENT_ALLOWLIST_EMPTY is surfaced as informational, never
87
+ * a silent free pass. */
87
88
  swapRecipientAllowlist?: string[]
88
89
  }
89
90
 
@@ -416,11 +417,16 @@ function appendProtocolSpecificConstraints(
416
417
 
417
418
  // Swap recipient (call_arg[3]): when the caller supplies
418
419
  // swapRecipientAllowlist, emit it as an `in` constraint on the recipient
419
- // arg. When absent, surface RECIPIENT_ALLOWLIST_EMPTY so the caller is
420
- // prompted; proceeding without an allowlist leaves the recipient
421
- // unconstrained in the predicate. The ambiguity is only surfaced when
422
- // the interpreter is enabled - if it isn't, the swap recipient is just
423
- // ignored (today's behaviour).
420
+ // arg. When absent, PIN the recipient to the recorded value - mirroring the
421
+ // SEP-41 `to` arg above: the recorded flow went to exactly one recipient, so
422
+ // pinning it is the minimal policy that permits exactly that flow. Leaving
423
+ // it unconstrained would permit an arbitrary recipient (an evil twin with
424
+ // call_arg[3] = attacker_wallet). RECIPIENT_ALLOWLIST_EMPTY is still
425
+ // surfaced, but as INFORMATIONAL (the recipient was pinned; here is how to
426
+ // widen it), never as a silent free pass. The recipient is only enforceable
427
+ // via the interpreter predicate, so the pin (and the ambiguity) apply only
428
+ // when the interpreter is enabled - otherwise the swap recipient is ignored
429
+ // (today's behaviour, matching the other SoroSwap constraints).
424
430
  const recipientArg = topLevel.args[3]
425
431
  if (swapRecipientAllowlist && swapRecipientAllowlist.length > 0) {
426
432
  const cond: IRCondition = {
@@ -434,9 +440,14 @@ function appendProtocolSpecificConstraints(
434
440
  ozConstraints.push(cond)
435
441
  }
436
442
  } else if (interpreterEnabled && recipientArg && recipientArg.type === 'address') {
443
+ interpreterConstraints.push({
444
+ op: 'in',
445
+ selector: { kind: 'arg', argIndex: 3, scalarType: 'address' },
446
+ values: [recipientArg.value],
447
+ })
437
448
  ambiguities.push({
438
449
  code: 'RECIPIENT_ALLOWLIST_EMPTY',
439
- question: `Recording shows the swap sending to ${recipientArg.value}. What recipient allowlist should the interpreter predicate bind? (omit to leave recipient unconstrained)`,
450
+ question: `No recipient allowlist supplied; the swap recipient (call_arg[3]) was pinned to the recorded value ${recipientArg.value}. To permit additional recipients, supply an allowlist (CLI: --recipient <C...|G...>, repeatable; MCP: userResponses.swapRecipientAllowlist) - it REPLACES this default pin.`,
440
451
  })
441
452
  }
442
453
  }
@@ -1,4 +1,5 @@
1
1
  import type { PredicateLeaf, PredicateNode, ScVal } from '../types.ts'
2
+ import { MAX_SCVAL_CLONE_DEPTH } from '../types.ts'
2
3
  import type { EvalContext } from './evaluate.ts'
3
4
  import { literalNumericBigInt } from './predicate-literals.ts'
4
5
 
@@ -44,8 +45,45 @@ const ADJACENT_ASSETS = [
44
45
  'CCWCLTASNDT57N3BCHOSVB5QWMV5URK4BXLDDF6ZZQYMBQ4OKZA3ZB2N',
45
46
  ] as const
46
47
 
47
- /** Build deterministic model-evaluated alternatives without mutating the intended call. */
48
- export function generateCases(predicate: PredicateNode, permitCtx: EvalContext): GeneratedCases {
48
+ // Phase 1 property-harness mutation dimensions excluded from the synth pipeline's
49
+ // self-verify call so existing fixtures still emit policies. The harness tests
50
+ // them as FINDINGS against the already-emitted policy.
51
+ const OVERPERMISSIVE_DIMENSIONS = ['argument_reorder'] as const
52
+
53
+ // The 15 dimensions the synth pipeline uses for self-verify and minimise.
54
+ const ORIGINAL_DIMENSIONS: string[] = [
55
+ 'amount',
56
+ 'asset',
57
+ 'contract',
58
+ 'function',
59
+ 'timing',
60
+ 'time_window',
61
+ 'invocation_count',
62
+ 'arg_amount_bound',
63
+ 'arg_bound',
64
+ 'scope_contract_fn_arg',
65
+ 'oracle_stale',
66
+ 'oracle_missing',
67
+ 'oracle_deviation_exceeded',
68
+ 'oracle_paused',
69
+ 'soroswap_allowed_path',
70
+ ]
71
+
72
+ export { ORIGINAL_DIMENSIONS, OVERPERMISSIVE_DIMENSIONS }
73
+
74
+ /** Build deterministic model-evaluated alternatives without mutating the intended call.
75
+ *
76
+ * @param predicate - the synthesized predicate
77
+ * @param permitCtx - EvalContext for the intended (permitted) call
78
+ * @param dimensions - optional whitelist of dimension names to emit; when omitted
79
+ * all known dimensions (including over-permissiveness mutations)
80
+ * are emitted. The synth pipeline passes ORIGINAL_DIMENSIONS so
81
+ * existing fixtures do not regress. */
82
+ export function generateCases(
83
+ predicate: PredicateNode,
84
+ permitCtx: EvalContext,
85
+ dimensions?: string[]
86
+ ): GeneratedCases {
49
87
  const facts = inspectPredicate(predicate)
50
88
  const denies: DenyCase[] = []
51
89
 
@@ -209,6 +247,33 @@ export function generateCases(predicate: PredicateNode, permitCtx: EvalContext):
209
247
  denies.push({ dimension: 'soroswap_allowed_path', ctx })
210
248
  }
211
249
 
250
+ // --- argument_reorder: swap first two address args ---
251
+ // Skipped when dimensions filter is active so the synth pipeline can emit a policy;
252
+ // the over-permissiveness harness then tests this mutation as a FINDING.
253
+ const constrainedArgIndices = new Set<number>()
254
+ for (const comparison of facts.comparisons) {
255
+ if (comparison.left.kind === 'call_arg') constrainedArgIndices.add(comparison.left.index)
256
+ }
257
+ for (const membership of facts.memberships) {
258
+ if (membership.needle.kind === 'call_arg') constrainedArgIndices.add(membership.needle.index)
259
+ }
260
+ const hasConstrainedArg = constrainedArgIndices.size > 0
261
+ if (
262
+ (!dimensions || dimensions.includes('argument_reorder')) &&
263
+ hasConstrainedArg &&
264
+ permitCtx.args.length >= 2 &&
265
+ permitCtx.args[0]?.type === 'address' &&
266
+ permitCtx.args[1]?.type === 'address'
267
+ ) {
268
+ const ctx = cloneContext(permitCtx)
269
+ // Guard above guarantees args[0] and args[1] exist and are address-typed.
270
+ const a0 = ctx.args[0] as ScVal
271
+ const a1 = ctx.args[1] as ScVal
272
+ ctx.args[0] = a1
273
+ ctx.args[1] = a0
274
+ denies.push({ dimension: 'argument_reorder', ctx })
275
+ }
276
+
212
277
  // Version mismatch, malformed predicates, master authorization, and nonce replay are install-time checks and are intentionally omitted from model-evaluated cases.
213
278
  return { permit: cloneContext(permitCtx), denies }
214
279
  }
@@ -439,9 +504,27 @@ function cloneContext(ctx: EvalContext): EvalContext {
439
504
  return cloned
440
505
  }
441
506
 
442
- function cloneScVal(value: ScVal): ScVal {
507
+ function cloneScVal(value: ScVal, depth = 0): ScVal {
508
+ // Recursion is bounded by MAX_SCVAL_CLONE_DEPTH so a hand-crafted nested-vec
509
+ // payload cannot RangeError the JS stack during deny-case mutation. Over-depth
510
+ // throws a ToolError-shaped error that the `synthesizeFromRecording` envelope
511
+ // (item 3) converts to a structured `{ok:false, error}`.
443
512
  if (value.type === 'vec') {
444
- return { type: 'vec', value: value.value.map(cloneScVal) }
513
+ if (depth >= MAX_SCVAL_CLONE_DEPTH) {
514
+ throw cloneDepthError(value)
515
+ }
516
+ return { type: 'vec', value: value.value.map((v) => cloneScVal(v, depth + 1)) }
445
517
  }
446
518
  return { ...value }
447
519
  }
520
+
521
+ function cloneDepthError(value: ScVal): never {
522
+ const err = new Error(
523
+ `ScVal clone depth exceeds MAX_SCVAL_CLONE_DEPTH (${MAX_SCVAL_CLONE_DEPTH})`
524
+ ) as Error & { code: string; severity: string; retryable: boolean; depthContext: unknown }
525
+ err.code = 'SYNTHESIS_ERROR'
526
+ err.severity = 'error'
527
+ err.retryable = false
528
+ err.depthContext = value.type
529
+ throw err
530
+ }
@@ -250,7 +250,7 @@ function evalArgEq(
250
250
  ): EvalResult {
251
251
  // eq(call_arg[i], literal_vec) -> EXACT ordered vector equality
252
252
  if (op === 'eq' && right.kind === 'literal_vec') {
253
- if (!actual || actual.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
253
+ if (actual?.type !== 'vec') return { permit: false, reason: 'ARG_MISMATCH' }
254
254
  return compareVecExact(actual.value, right.elements, ctx)
255
255
  ? { permit: true }
256
256
  : { permit: false, reason: 'ARG_MISMATCH' }
@@ -267,7 +267,7 @@ function evalArgEq(
267
267
 
268
268
  // eq(call_arg[i], literal_i128) -> BigInt compare
269
269
  if (op === 'eq' && right.kind === 'literal_i128') {
270
- if (!actual || actual.type !== 'i128') return { permit: false, reason: 'ARG_MISMATCH' }
270
+ if (actual?.type !== 'i128') return { permit: false, reason: 'ARG_MISMATCH' }
271
271
  return BigInt(actual.value) === BigInt(right.value)
272
272
  ? { permit: true }
273
273
  : { permit: false, reason: 'ARG_MISMATCH' }
@@ -1,5 +1,9 @@
1
1
  // src/synth/index.ts - re-export the synthesizer front-ends.
2
2
 
3
+ // Exported because `swapRecipientAllowlist` is part of the public synthesis
4
+ // input: a caller assembling one needs the same validator the schema applies,
5
+ // and re-deriving it elsewhere means a second address check that can drift.
6
+ export { isStellarAddress } from './address.ts'
3
7
  export {
4
8
  type ComposeOptions,
5
9
  type ComposeResult,
@@ -4,7 +4,11 @@ import type { EvalContext } from './evaluate.ts'
4
4
  import { runHarness } from './harness.ts'
5
5
 
6
6
  /** Remove top-level conjuncts only when the current and regenerated batteries still deny. */
7
- export function minimize(predicate: PredicateNode, permitCtx: EvalContext): PredicateNode {
7
+ export function minimize(
8
+ predicate: PredicateNode,
9
+ permitCtx: EvalContext,
10
+ dimensions?: string[]
11
+ ): PredicateNode {
8
12
  if (predicate.op !== 'and') return predicate
9
13
 
10
14
  let children = [...predicate.children]
@@ -12,10 +16,10 @@ export function minimize(predicate: PredicateNode, permitCtx: EvalContext): Pred
12
16
 
13
17
  while (index < children.length) {
14
18
  const current: PredicateNode = { op: 'and', children }
15
- const currentCases = generateCases(current, permitCtx)
19
+ const currentCases = generateCases(current, permitCtx, dimensions)
16
20
  const candidateChildren = children.filter((_, childIndex) => childIndex !== index)
17
21
  const candidate: PredicateNode = { op: 'and', children: candidateChildren }
18
- const candidateCases = generateCases(candidate, permitCtx)
22
+ const candidateCases = generateCases(candidate, permitCtx, dimensions)
19
23
  const verificationCases = {
20
24
  permit: candidateCases.permit,
21
25
  denies: mergeDenyCases(currentCases.denies, candidateCases.denies),