@crediolabs/policy-synth 0.2.0 → 0.3.1

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 (249) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/interpreter/adapter.d.ts +19 -25
  3. package/dist/adapters/interpreter/adapter.js +54 -487
  4. package/dist/errors.d.ts +1 -1
  5. package/dist/index.d.ts +1 -4
  6. package/dist/index.js +1 -4
  7. package/dist/install/build-add-context-rule.d.ts +3 -19
  8. package/dist/install/build-add-context-rule.js +3 -98
  9. package/dist/install/build-install-policy.d.ts +4 -53
  10. package/dist/install/build-install-policy.js +44 -123
  11. package/dist/install/index.d.ts +0 -2
  12. package/dist/install/index.js +0 -7
  13. package/dist/predicate/decode.d.ts +1 -1
  14. package/dist/predicate/decode.js +2 -70
  15. package/dist/predicate/encode.js +45 -229
  16. package/dist/predicate/from-json.js +1 -42
  17. package/dist/record/decode.js +3 -8
  18. package/dist/record/freshness.d.ts +17 -11
  19. package/dist/record/freshness.js +28 -18
  20. package/dist/review-card/builder.d.ts +4 -3
  21. package/dist/review-card/builder.js +18 -162
  22. package/dist/review-card/cross-check.js +2 -105
  23. package/dist/review-card/render-leaf.d.ts +4 -0
  24. package/dist/review-card/render-leaf.js +47 -0
  25. package/dist/run/index.d.ts +36 -139
  26. package/dist/run/index.js +125 -437
  27. package/dist/run/schemas.d.ts +168 -1929
  28. package/dist/run/schemas.js +40 -269
  29. package/dist/{synth → simulate}/deny-cases.d.ts +4 -8
  30. package/dist/simulate/deny-cases.js +282 -0
  31. package/dist/simulate/evaluate.d.ts +18 -0
  32. package/dist/simulate/evaluate.js +271 -0
  33. package/dist/simulate/index.d.ts +4 -0
  34. package/dist/simulate/index.js +8 -0
  35. package/dist/synth/compose-from-recording.d.ts +24 -51
  36. package/dist/synth/compose-from-recording.js +120 -282
  37. package/dist/synth/index.d.ts +0 -5
  38. package/dist/synth/index.js +0 -5
  39. package/dist/synth/synthesize-from-recording.d.ts +1 -29
  40. package/dist/synth/synthesize-from-recording.js +41 -356
  41. package/dist/types.d.ts +8 -60
  42. package/dist/types.js +5 -2
  43. package/dist-cjs/adapters/interpreter/adapter.d.ts +19 -25
  44. package/dist-cjs/adapters/interpreter/adapter.js +56 -489
  45. package/dist-cjs/errors.d.ts +1 -1
  46. package/dist-cjs/index.d.ts +1 -4
  47. package/dist-cjs/index.js +1 -4
  48. package/dist-cjs/install/build-add-context-rule.d.ts +3 -19
  49. package/dist-cjs/install/build-add-context-rule.js +1 -97
  50. package/dist-cjs/install/build-install-policy.d.ts +4 -53
  51. package/dist-cjs/install/build-install-policy.js +44 -122
  52. package/dist-cjs/install/index.d.ts +0 -2
  53. package/dist-cjs/install/index.js +2 -23
  54. package/dist-cjs/predicate/decode.d.ts +1 -1
  55. package/dist-cjs/predicate/decode.js +2 -70
  56. package/dist-cjs/predicate/encode.js +45 -229
  57. package/dist-cjs/predicate/from-json.js +1 -42
  58. package/dist-cjs/record/decode.js +3 -8
  59. package/dist-cjs/record/freshness.d.ts +17 -11
  60. package/dist-cjs/record/freshness.js +28 -18
  61. package/dist-cjs/review-card/builder.d.ts +4 -3
  62. package/dist-cjs/review-card/builder.js +23 -167
  63. package/dist-cjs/review-card/cross-check.js +7 -110
  64. package/dist-cjs/review-card/render-leaf.d.ts +4 -0
  65. package/dist-cjs/review-card/render-leaf.js +52 -0
  66. package/dist-cjs/run/index.d.ts +36 -139
  67. package/dist-cjs/run/index.js +125 -444
  68. package/dist-cjs/run/schemas.d.ts +168 -1929
  69. package/dist-cjs/run/schemas.js +41 -270
  70. package/dist-cjs/{synth → simulate}/deny-cases.d.ts +4 -8
  71. package/dist-cjs/simulate/deny-cases.js +286 -0
  72. package/dist-cjs/simulate/evaluate.d.ts +18 -0
  73. package/dist-cjs/simulate/evaluate.js +274 -0
  74. package/dist-cjs/simulate/index.d.ts +4 -0
  75. package/dist-cjs/simulate/index.js +13 -0
  76. package/dist-cjs/synth/compose-from-recording.d.ts +24 -51
  77. package/dist-cjs/synth/compose-from-recording.js +120 -282
  78. package/dist-cjs/synth/index.d.ts +0 -5
  79. package/dist-cjs/synth/index.js +1 -11
  80. package/dist-cjs/synth/synthesize-from-recording.d.ts +1 -29
  81. package/dist-cjs/synth/synthesize-from-recording.js +39 -354
  82. package/dist-cjs/types.d.ts +8 -60
  83. package/dist-cjs/types.js +6 -3
  84. package/package.json +5 -5
  85. package/src/adapters/interpreter/adapter.ts +76 -572
  86. package/src/errors.ts +0 -19
  87. package/src/index.ts +1 -4
  88. package/src/install/build-add-context-rule.ts +5 -139
  89. package/src/install/build-install-policy.ts +93 -214
  90. package/src/install/index.ts +0 -34
  91. package/src/predicate/decode.ts +2 -70
  92. package/src/predicate/encode.ts +49 -261
  93. package/src/predicate/from-json.ts +1 -43
  94. package/src/record/decode.ts +3 -8
  95. package/src/record/freshness.ts +29 -18
  96. package/src/review-card/builder.ts +19 -168
  97. package/src/review-card/cross-check.ts +3 -105
  98. package/src/review-card/render-leaf.ts +48 -0
  99. package/src/run/index.ts +144 -572
  100. package/src/run/schemas.ts +42 -291
  101. package/src/simulate/deny-cases.ts +334 -0
  102. package/src/simulate/evaluate.ts +284 -0
  103. package/src/simulate/index.ts +11 -0
  104. package/src/synth/compose-from-recording.ts +148 -347
  105. package/src/synth/index.ts +0 -13
  106. package/src/synth/synthesize-from-recording.ts +43 -456
  107. package/src/types.ts +13 -49
  108. package/dist/adapters/interpreter/index.d.ts +0 -1
  109. package/dist/adapters/interpreter/index.js +0 -2
  110. package/dist/adapters/oz/adapter.d.ts +0 -20
  111. package/dist/adapters/oz/adapter.js +0 -295
  112. package/dist/adapters/oz/index.d.ts +0 -1
  113. package/dist/adapters/oz/index.js +0 -2
  114. package/dist/codegen/compile-gate.d.ts +0 -33
  115. package/dist/codegen/compile-gate.js +0 -124
  116. package/dist/codegen/index.d.ts +0 -2
  117. package/dist/codegen/index.js +0 -8
  118. package/dist/codegen/template.d.ts +0 -18
  119. package/dist/codegen/template.js +0 -148
  120. package/dist/install/authority-overlap.d.ts +0 -134
  121. package/dist/install/authority-overlap.js +0 -0
  122. package/dist/install/build-install-predicate.d.ts +0 -96
  123. package/dist/install/build-install-predicate.js +0 -444
  124. package/dist/install/build-merge-policy.d.ts +0 -70
  125. package/dist/install/build-merge-policy.js +0 -130
  126. package/dist/install/plan-merge-policy.d.ts +0 -49
  127. package/dist/install/plan-merge-policy.js +0 -86
  128. package/dist/install/read-account-rules.d.ts +0 -100
  129. package/dist/install/read-account-rules.js +0 -283
  130. package/dist/ir/index.d.ts +0 -1
  131. package/dist/ir/index.js +0 -2
  132. package/dist/ir/types.d.ts +0 -140
  133. package/dist/ir/types.js +0 -11
  134. package/dist/mandate/index.d.ts +0 -2
  135. package/dist/mandate/index.js +0 -2
  136. package/dist/mandate/to-ir.d.ts +0 -3
  137. package/dist/mandate/to-ir.js +0 -60
  138. package/dist/mandate/types.d.ts +0 -20
  139. package/dist/mandate/types.js +0 -8
  140. package/dist/seams/index.d.ts +0 -1
  141. package/dist/seams/index.js +0 -2
  142. package/dist/seams/types.d.ts +0 -66
  143. package/dist/seams/types.js +0 -11
  144. package/dist/synth/deny-cases.js +0 -562
  145. package/dist/synth/evaluate.d.ts +0 -39
  146. package/dist/synth/evaluate.js +0 -551
  147. package/dist/synth/harness.d.ts +0 -28
  148. package/dist/synth/harness.js +0 -47
  149. package/dist/synth/minimize.d.ts +0 -4
  150. package/dist/synth/minimize.js +0 -38
  151. package/dist/synth/permit-context.d.ts +0 -15
  152. package/dist/synth/permit-context.js +0 -116
  153. package/dist/synth/predicate-literals.d.ts +0 -5
  154. package/dist/synth/predicate-literals.js +0 -25
  155. package/dist/synth/synthesize-from-mandate.d.ts +0 -20
  156. package/dist/synth/synthesize-from-mandate.js +0 -59
  157. package/dist/verify/envelope.d.ts +0 -15
  158. package/dist/verify/envelope.js +0 -22
  159. package/dist/verify/index.d.ts +0 -3
  160. package/dist/verify/index.js +0 -3
  161. package/dist/verify/simulate.d.ts +0 -31
  162. package/dist/verify/simulate.js +0 -258
  163. package/dist/verify/verify.d.ts +0 -21
  164. package/dist/verify/verify.js +0 -189
  165. package/dist-cjs/adapters/interpreter/index.d.ts +0 -1
  166. package/dist-cjs/adapters/interpreter/index.js +0 -8
  167. package/dist-cjs/adapters/oz/adapter.d.ts +0 -20
  168. package/dist-cjs/adapters/oz/adapter.js +0 -300
  169. package/dist-cjs/adapters/oz/index.d.ts +0 -1
  170. package/dist-cjs/adapters/oz/index.js +0 -8
  171. package/dist-cjs/codegen/compile-gate.d.ts +0 -33
  172. package/dist-cjs/codegen/compile-gate.js +0 -128
  173. package/dist-cjs/codegen/index.d.ts +0 -2
  174. package/dist-cjs/codegen/index.js +0 -14
  175. package/dist-cjs/codegen/template.d.ts +0 -18
  176. package/dist-cjs/codegen/template.js +0 -151
  177. package/dist-cjs/install/authority-overlap.d.ts +0 -134
  178. package/dist-cjs/install/authority-overlap.js +0 -0
  179. package/dist-cjs/install/build-install-predicate.d.ts +0 -96
  180. package/dist-cjs/install/build-install-predicate.js +0 -479
  181. package/dist-cjs/install/build-merge-policy.d.ts +0 -70
  182. package/dist-cjs/install/build-merge-policy.js +0 -134
  183. package/dist-cjs/install/plan-merge-policy.d.ts +0 -49
  184. package/dist-cjs/install/plan-merge-policy.js +0 -90
  185. package/dist-cjs/install/read-account-rules.d.ts +0 -100
  186. package/dist-cjs/install/read-account-rules.js +0 -296
  187. package/dist-cjs/ir/index.d.ts +0 -1
  188. package/dist-cjs/ir/index.js +0 -3
  189. package/dist-cjs/ir/types.d.ts +0 -140
  190. package/dist-cjs/ir/types.js +0 -12
  191. package/dist-cjs/mandate/index.d.ts +0 -2
  192. package/dist-cjs/mandate/index.js +0 -6
  193. package/dist-cjs/mandate/to-ir.d.ts +0 -3
  194. package/dist-cjs/mandate/to-ir.js +0 -63
  195. package/dist-cjs/mandate/types.d.ts +0 -20
  196. package/dist-cjs/mandate/types.js +0 -9
  197. package/dist-cjs/seams/index.d.ts +0 -1
  198. package/dist-cjs/seams/index.js +0 -3
  199. package/dist-cjs/seams/types.d.ts +0 -66
  200. package/dist-cjs/seams/types.js +0 -12
  201. package/dist-cjs/synth/deny-cases.js +0 -568
  202. package/dist-cjs/synth/evaluate.d.ts +0 -39
  203. package/dist-cjs/synth/evaluate.js +0 -554
  204. package/dist-cjs/synth/harness.d.ts +0 -28
  205. package/dist-cjs/synth/harness.js +0 -50
  206. package/dist-cjs/synth/minimize.d.ts +0 -4
  207. package/dist-cjs/synth/minimize.js +0 -41
  208. package/dist-cjs/synth/permit-context.d.ts +0 -15
  209. package/dist-cjs/synth/permit-context.js +0 -119
  210. package/dist-cjs/synth/predicate-literals.d.ts +0 -5
  211. package/dist-cjs/synth/predicate-literals.js +0 -28
  212. package/dist-cjs/synth/synthesize-from-mandate.d.ts +0 -20
  213. package/dist-cjs/synth/synthesize-from-mandate.js +0 -62
  214. package/dist-cjs/verify/envelope.d.ts +0 -15
  215. package/dist-cjs/verify/envelope.js +0 -23
  216. package/dist-cjs/verify/index.d.ts +0 -3
  217. package/dist-cjs/verify/index.js +0 -8
  218. package/dist-cjs/verify/simulate.d.ts +0 -31
  219. package/dist-cjs/verify/simulate.js +0 -261
  220. package/dist-cjs/verify/verify.d.ts +0 -21
  221. package/dist-cjs/verify/verify.js +0 -192
  222. package/src/adapters/interpreter/index.ts +0 -8
  223. package/src/adapters/oz/adapter.ts +0 -376
  224. package/src/adapters/oz/index.ts +0 -9
  225. package/src/codegen/compile-gate.ts +0 -167
  226. package/src/codegen/index.ts +0 -17
  227. package/src/codegen/template.ts +0 -165
  228. package/src/install/authority-overlap.ts +0 -0
  229. package/src/install/build-merge-policy.ts +0 -219
  230. package/src/install/plan-merge-policy.ts +0 -133
  231. package/src/install/read-account-rules.ts +0 -376
  232. package/src/ir/index.ts +0 -13
  233. package/src/ir/types.ts +0 -132
  234. package/src/mandate/index.ts +0 -4
  235. package/src/mandate/to-ir.ts +0 -71
  236. package/src/mandate/types.ts +0 -21
  237. package/src/seams/index.ts +0 -11
  238. package/src/seams/types.ts +0 -81
  239. package/src/synth/deny-cases.ts +0 -663
  240. package/src/synth/evaluate.ts +0 -613
  241. package/src/synth/harness.ts +0 -68
  242. package/src/synth/minimize.ts +0 -48
  243. package/src/synth/permit-context.ts +0 -136
  244. package/src/synth/predicate-literals.ts +0 -27
  245. package/src/synth/synthesize-from-mandate.ts +0 -82
  246. package/src/verify/envelope.ts +0 -28
  247. package/src/verify/index.ts +0 -5
  248. package/src/verify/simulate.ts +0 -311
  249. package/src/verify/verify.ts +0 -243
package/src/run/index.ts CHANGED
@@ -18,28 +18,19 @@
18
18
  // drive the CLI (which calls into the same core directly without MCP).
19
19
 
20
20
  import { createHash } from 'node:crypto'
21
- import { rpc, xdr } from '@stellar/stellar-sdk'
21
+ import { rpc } from '@stellar/stellar-sdk'
22
22
  import {
23
23
  type ErrorCode,
24
- type MandateSpec,
25
24
  type Network,
26
- type OzAdapterConfig,
27
25
  type PredicateNode,
28
26
  type ProposedPolicy,
29
- placeholderOzConfig,
30
27
  type RecordedTransaction,
31
28
  recordTransaction,
32
29
  type SynthesizeFromRecordingOptions,
33
- synthesizeFromMandate,
34
30
  synthesizeFromRecording,
35
31
  type ToolError,
36
32
  type ToolResponse,
37
33
  } from '../index.ts'
38
- import {
39
- type AuthorityOverlap,
40
- type ContextType,
41
- findAuthorityOverlaps,
42
- } from '../install/authority-overlap.ts'
43
34
  import {
44
35
  type BuildInstallPolicyResult,
45
36
  type BuildRevokePolicyResult,
@@ -48,29 +39,17 @@ import {
48
39
  type InstallRpcClient,
49
40
  rpcClientFromServer,
50
41
  } from '../install/build-install-policy.ts'
51
- import { buildMergePolicyXdr } from '../install/build-merge-policy.ts'
52
42
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
53
- import { planMergePolicy } from '../install/plan-merge-policy.ts'
54
- import {
55
- accountRuleReaderFromServer,
56
- collectObservedRules,
57
- nonceLedgerKey,
58
- } from '../install/read-account-rules.ts'
59
- import { decodePredicate } from '../predicate/decode.ts'
60
- import { encodePredicate } from '../predicate/encode.ts'
61
- import type { SimulationResult } from '../verify/envelope.ts'
62
- import { simulatePolicy, verifyPolicy } from '../verify/index.ts'
43
+ import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
63
44
  import {
64
45
  type GetInterpreterInfoInput,
65
46
  GetInterpreterInfoInputSchema,
66
47
  type InstallPolicyInput,
67
48
  InstallPolicyInputSchema,
68
- type MergePolicyInput,
69
- MergePolicyInputSchema,
70
49
  NETWORK_PASSPHRASES,
71
50
  PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
72
51
  PINNED_INTERPRETER_GRAMMAR_VERSION,
73
- PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK,
52
+ PINNED_INTERPRETER_WASM_SHA256,
74
53
  type RecordTransactionInput,
75
54
  RecordTransactionInputSchema,
76
55
  type RevokePolicyInput,
@@ -103,26 +82,21 @@ export {
103
82
  InstallPolicyInputSchema,
104
83
  InterpreterOptionsSchema,
105
84
  MAINNET_RPC_URL,
106
- MandateSpecSchema,
107
85
  NetworkSchema,
108
- OraclePriceFixtureSchema,
109
- OzAdapterConfigSchema,
110
86
  PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
111
87
  PINNED_INTERPRETER_GRAMMAR_VERSION,
112
88
  PINNED_INTERPRETER_MAINNET_ADDRESS,
113
89
  PINNED_INTERPRETER_TESTNET_ADDRESS,
114
- PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK,
90
+ PINNED_INTERPRETER_WASM_SHA256,
115
91
  PredicateLeafSchema,
116
92
  PredicateNodeSchema,
117
93
  RecordedTransactionSchema,
118
94
  RecordTransactionInputSchema,
119
95
  RevokePolicyInputSchema,
120
96
  RPC_URL_BY_NETWORK,
121
- SimulatePolicyInputSchema,
122
97
  SynthesizePolicyInputSchema,
123
98
  TESTNET_RPC_URL,
124
99
  ToolErrorSchema,
125
- VerifyPolicyInputSchema,
126
100
  } from './schemas.ts'
127
101
 
128
102
  export type RunRecordTransactionInput = RecordTransactionInput
@@ -168,10 +142,7 @@ export async function runRecordTransaction(
168
142
  ): Promise<ToolResponse<RecordedTransaction>> {
169
143
  const parsed = RecordTransactionInputSchema.safeParse(raw)
170
144
  if (!parsed.success) {
171
- return {
172
- ok: false,
173
- error: validationError('record_transaction', parsed.error.issues),
174
- }
145
+ return { ok: false, error: validationError('record_transaction', parsed.error.issues) }
175
146
  }
176
147
  const input: RecordTransactionInput = parsed.data
177
148
  // Strip the wire-only `confidenceOverride` and pass the rest straight through.
@@ -186,15 +157,11 @@ export async function runRecordTransaction(
186
157
  try {
187
158
  return await recordTransaction(coreInput)
188
159
  } catch (e) {
189
- return {
190
- ok: false,
191
- error: caughtError('record_transaction', 'RECORDING_FAILED', e),
192
- }
160
+ return toolFailure('record_transaction', e)
193
161
  }
194
162
  }
195
163
 
196
164
  /** `synthesize_policy` body - discriminated union on `source`:
197
- * - `mandate` -> synthesizeFromMandate
198
165
  * - `recording` -> synthesizeFromRecording
199
166
  * Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
200
167
  * letting the agent pick the deterministic or the inferred path. The CLI
@@ -209,155 +176,37 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
209
176
  ToolResponse<ProposedPolicy> & {
210
177
  explain?: {
211
178
  predicateTree: PredicateNode | null
212
- simulation: SimulationResult
213
179
  }
214
180
  }
215
181
  > {
216
182
  const parsed = SynthesizePolicyInputSchema.safeParse(raw)
217
183
  if (!parsed.success) {
218
- return {
219
- ok: false,
220
- error: validationError('synthesize_policy', parsed.error.issues),
221
- }
184
+ return { ok: false, error: validationError('synthesize_policy', parsed.error.issues) }
222
185
  }
223
186
  const input = parsed.data
224
- const ozConfig: OzAdapterConfig = resolveOzConfig(input)
225
187
 
226
188
  try {
227
- if (input.source === 'mandate') {
228
- // Zod's optional fields widen to `T | undefined`, which the core's
229
- // exact-optional MandateSpec rejects; the schema already validated the
230
- // shape, so assert it (same pattern as the recordedTx cast below).
231
- return await synthesizeFromMandate(
232
- input.mandate as MandateSpec,
233
- ozConfig,
234
- input.explain === true ? { explain: true } : {}
235
- )
236
- }
237
- // recording source
238
189
  const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
239
- return await synthesizeFromRecording(
240
- recorded,
241
- {
242
- network: input.network,
243
- ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
244
- ...(input.confidenceOverride !== undefined
245
- ? { confidenceOverride: input.confidenceOverride }
246
- : {}),
247
- ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
248
- ...(input.explain === true ? { explain: true } : {}),
249
- } as SynthesizeFromRecordingOptions,
250
- ozConfig
251
- )
252
- } catch (e) {
253
- return {
254
- ok: false,
255
- error: caughtError('synthesize_policy', 'SYNTHESIS_ERROR', e),
256
- }
257
- }
258
- }
259
-
260
- function resolveOzConfig(input: SynthesizePolicyInput): OzAdapterConfig {
261
- if (input.ozConfig) return input.ozConfig
262
- // The mandate path is network-agnostic; fall back to mainnet so the
263
- // placeholder OZ instance addresses are deterministic.
264
- return placeholderOzConfig('mainnet')
265
- }
266
-
267
- /** `simulate_policy` body - thin wrapper over `simulatePolicy`. The engine
268
- * already returns fail-closed `{ok:false, error}` for runtime failures
269
- * (SIMULATION_ERROR), so the try/catch envelope is for raw SDK throws
270
- * only - same pattern as the other two wrappers. The predicate is
271
- * passed inline (stateless by design; no `proposed_policy_id` lookup). */
272
- export async function runSimulatePolicy(raw: unknown): Promise<ToolResponse<SimulationResult>> {
273
- const parsed = SimulatePolicyInputSchema.safeParse(raw)
274
- if (!parsed.success) {
275
- return {
276
- ok: false,
277
- error: validationError('simulate_policy', parsed.error.issues),
278
- }
279
- }
280
- const input: SimulatePolicyInput = parsed.data
281
- try {
282
- // The recursive PredicateNodeSchema + ContractInvocationSchema are
283
- // typed `z.ZodType<unknown>` to survive TS's circular inference; the
284
- // engine wants typed `PredicateNode | null` + `RecordedTransaction`.
285
- // The schema already validated the shape, so assert through the
286
- // unknown back to the core types. Same pattern as the recordedTx
287
- // cast in `runSynthesizePolicy`.
288
- return simulatePolicy(
289
- input.predicate as PredicateNode | null,
290
- input.permitTx as RecordedTransaction,
291
- {
292
- ...(input.validUntilLedger !== undefined
293
- ? { validUntilLedger: input.validUntilLedger }
294
- : {}),
295
- ...(input.oraclePricesByAsset !== undefined
296
- ? { oraclePricesByAsset: input.oraclePricesByAsset }
297
- : {}),
298
- }
299
- )
300
- } catch (e) {
301
- return {
302
- ok: false,
303
- error: caughtError('simulate_policy', 'SIMULATION_ERROR', e),
304
- }
305
- }
306
- }
307
-
308
- /** `verify_policy` body - thin wrapper over `verifyPolicy`. The engine
309
- * already returns `{ok:false, error}` with code VERIFICATION_FAILED when
310
- * the minimality check fails; the try/catch envelope is for raw SDK
311
- * throws only. Mirrors `runSimulatePolicy` exactly. */
312
- export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>> {
313
- const parsed = VerifyPolicyInputSchema.safeParse(raw)
314
- if (!parsed.success) {
315
- return {
316
- ok: false,
317
- error: validationError('verify_policy', parsed.error.issues),
318
- }
319
- }
320
- const input: VerifyPolicyInput = parsed.data
321
- try {
322
- // Same cast as runSimulatePolicy: the recursive schemas are typed
323
- // `unknown`; the engine wants typed `PredicateNode` +
324
- // `RecordedTransaction`. The schema already validated the shape.
325
- return verifyPolicy(input.predicate as PredicateNode, input.permitTx as RecordedTransaction, {
326
- ...(input.validUntilLedger !== undefined ? { validUntilLedger: input.validUntilLedger } : {}),
327
- ...(input.oraclePricesByAsset !== undefined
328
- ? { oraclePricesByAsset: input.oraclePricesByAsset }
190
+ return await synthesizeFromRecording(recorded, {
191
+ network: input.network,
192
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
193
+ ...(input.confidenceOverride !== undefined
194
+ ? { confidenceOverride: input.confidenceOverride }
329
195
  : {}),
330
- })
196
+ ...(input.interpreter !== undefined ? { interpreter: input.interpreter } : {}),
197
+ ...(input.explain === true ? { explain: true } : {}),
198
+ } as SynthesizeFromRecordingOptions)
331
199
  } catch (e) {
332
- return {
333
- ok: false,
334
- error: caughtError('verify_policy', 'VERIFICATION_FAILED', e),
335
- }
200
+ return toolFailure('synthesize_policy', e)
336
201
  }
337
202
  }
338
203
 
339
- /** `install_policy` body - thin wrapper over `buildInstallPolicyXdr`.
340
- * Returns the unsigned Soroban transaction envelope (base64 XDR) the
341
- * wallet signs. The wallet signature IS the user-confirmation step - no
342
- * `action_id` two-call pair (the server is stateless, see server.ts:10-12).
343
- * One call installs the policy outright: `add_context_rule` carries the
344
- * predicate to the interpreter in its `policies` install_param, so no
345
- * separate `interpreter.install` call is needed or possible.
346
- *
347
- * Default-deny: an interpreter policy address other than the pinned
348
- * interpreter for the selected network is REFUSED (the smart account
349
- * would delegate to an interpreter the caller controls); the same
350
- * applies to a non-pinned RPC URL (the auth nonce the wallet signs
351
- * comes from the RPC). Both gates accept an explicit opt-in flag.
352
- * Pin selection follows `input.network` (defaults to `testnet` so the
353
- * pre-mainnet callers keep working unchanged). */
354
- export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<InstallPolicyResult>> {
204
+ export async function runInstallPolicy(
205
+ raw: unknown
206
+ ): Promise<ToolResponse<BuildInstallPolicyResult>> {
355
207
  const parsed = InstallPolicyInputSchema.safeParse(raw)
356
208
  if (!parsed.success) {
357
- return {
358
- ok: false,
359
- error: validationError('install_policy', parsed.error.issues),
360
- }
209
+ return { ok: false, error: validationError('install_policy', parsed.error.issues) }
361
210
  }
362
211
  const input: InstallPolicyInput = parsed.data
363
212
  const network: Network = input.network ?? 'testnet'
@@ -386,10 +235,7 @@ export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<Insta
386
235
  try {
387
236
  rpcClient = buildRpcClientFromInput(input.rpcUrl, network)
388
237
  } catch (e) {
389
- return {
390
- ok: false,
391
- error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e),
392
- }
238
+ return toolFailure('install_policy', e)
393
239
  }
394
240
  try {
395
241
  const interpreterPolicy = input.rule.policies.find((p) => p.kind === 'interpreter')
@@ -397,27 +243,6 @@ export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<Insta
397
243
  const predicateHash = createHash('sha256')
398
244
  .update(Buffer.from(encodedPredicate, 'base64'))
399
245
  .digest('hex')
400
-
401
- // ---- Cross-rule authority scan ----
402
- // OZ enforces only the policies of the rule the caller names, so a signer
403
- // who also sits in a wider rule keeps that wider authority no matter what
404
- // this predicate says. Refuse by default when the wider rule has no policy
405
- // at all, because that makes this install decorative.
406
- const authorityScan: AuthorityScanReport | undefined = input.skipAuthorityScan
407
- ? { ran: false, skipped: true, reason: 'skipped at caller request', overlaps: [] }
408
- : await scanAuthorityOverlap({
409
- smartAccount: input.smartAccount,
410
- interpreterAddress: expectedInterpreter,
411
- rule: input.rule,
412
- encodedPredicate,
413
- rpcUrl: input.rpcUrl ?? expectedRpc,
414
- network,
415
- })
416
- const overlapError = enforceAuthorityScan(authorityScan, input.allowAuthorityOverlap)
417
- if (overlapError) {
418
- return { ok: false, error: overlapError }
419
- }
420
-
421
246
  const result = await buildInstallPolicyXdr({
422
247
  smartAccount: input.smartAccount,
423
248
  sourceAccount: input.sourceAccount,
@@ -429,349 +254,9 @@ export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<Insta
429
254
  rpc: rpcClient,
430
255
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
431
256
  })
432
- return { ok: true, data: authorityScan ? { ...result, authorityScan } : result }
433
- } catch (e) {
434
- return {
435
- ok: false,
436
- error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e),
437
- }
438
- }
439
- }
440
-
441
- /** Default-deny on the cross-rule scan.
442
- *
443
- * Refuses whenever the scan cannot establish that this policy binds the calls
444
- * it names. An unpoliced neighbour provably does not constrain them. An
445
- * opaque one, policed by a contract this tool cannot decode, is not KNOWN to,
446
- * and "not known to" is not "safe" - the same posture as the interpreter and
447
- * RPC pins. An incomplete scan is refused for the same reason: the overlap
448
- * list is then a subset of the account, so an empty list proves nothing.
449
- *
450
- * `not-restricting` is reported but does NOT block. Both rules are ours and
451
- * both constrain the calls, and the conjunction remedy is offered; refusing
452
- * there would also block the legitimate act of adding a separate capability,
453
- * which OZ composes correctly as a union.
454
- *
455
- * Returns a ToolError or null, matching `enforceInterpreterPin`. */
456
- export function enforceAuthorityScan(
457
- scan: AuthorityScanReport | undefined,
458
- allowOverlap: boolean | undefined
459
- ): ToolError | null {
460
- if (!scan || allowOverlap === true) return null
461
-
462
- // Default-deny by exclusion rather than by enumeration: anything that is not
463
- // the one known-safe severity blocks. Listing the blocking severities
464
- // instead would mean a severity added later silently passes until someone
465
- // remembers to add it here, and the safe direction is the opposite.
466
- const blocking = scan.overlaps.filter((o) => o.severity !== 'not-restricting')
467
- const unpoliced = blocking.filter((o) => o.severity === 'bypass').map((o) => o.ruleId)
468
- const opaque = blocking.filter((o) => o.severity === 'unknown').map((o) => o.ruleId)
469
- const unrecognised = blocking
470
- .filter((o) => o.severity !== 'bypass' && o.severity !== 'unknown')
471
- .map((o) => o.ruleId)
472
-
473
- if (blocking.length > 0) {
474
- const parts = [
475
- unpoliced.length > 0 ? `rule ${unpoliced.join(', ')} has no policy attached` : '',
476
- opaque.length > 0
477
- ? `rule ${opaque.join(', ')} is policed by a contract this tool cannot decode`
478
- : '',
479
- unrecognised.length > 0
480
- ? `rule ${unrecognised.join(', ')} carries an overlap this build does not recognise`
481
- : '',
482
- ].filter(Boolean)
483
- return {
484
- code: 'INSTALL_BUILD_FAILED',
485
- message: `install_policy: a signer of this rule can already make the same calls through another context rule, so this policy is not established to restrict them: ${parts.join('; ')}. Remove the shared signer from that rule, attach a policy this tool can read, or set allowAuthorityOverlap: true to install anyway`,
486
- severity: 'error',
487
- retryable: false,
488
- remediation: { toolCall: { name: 'install_policy', args: {} } },
489
- }
490
- }
491
-
492
- // A scan that was meant to run and threw tells us nothing. Reporting it in
493
- // a string field and proceeding is the same fail-open shape as an incomplete
494
- // scan, and a caller that does not read `ran` cannot tell it from a clean
495
- // result.
496
- if (scan.ran === false && scan.skipped !== true) {
497
- return {
498
- code: 'INSTALL_BUILD_FAILED',
499
- message: `install_policy: the cross-rule authority scan could not run (${scan.reason ?? 'unknown error'}), so it cannot establish that this policy restricts anything; retry, or set allowAuthorityOverlap: true to install without that assurance`,
500
- severity: 'error',
501
- retryable: true,
502
- remediation: { toolCall: { name: 'install_policy', args: {} } },
503
- }
504
- }
505
-
506
- if (scan.incomplete === true) {
507
- return {
508
- code: 'INSTALL_BUILD_FAILED',
509
- message:
510
- 'install_policy: the account has more context rules than the scan could account for, so the overlap result is incomplete and cannot establish that this policy restricts anything; set allowAuthorityOverlap: true to install without that assurance',
511
- severity: 'error',
512
- retryable: false,
513
- remediation: { toolCall: { name: 'install_policy', args: {} } },
514
- }
515
- }
516
-
517
- return null
518
- }
519
-
520
- /** The install response, plus what the cross-rule scan found. The scan is
521
- * advisory data about the account, not part of the transaction, so it is
522
- * additive: a caller that ignores it gets exactly the previous shape. */
523
- export type InstallPolicyResult = BuildInstallPolicyResult & {
524
- authorityScan?: AuthorityScanReport
525
- }
526
-
527
- /** What the cross-rule scan found, carried on the install response so the
528
- * review surface can show it alongside the transaction being signed.
529
- *
530
- * SCOPE, and it is narrow: this answers "can a signer OF THIS RULE reach the
531
- * same calls through a different rule". A rule sharing no signer with this
532
- * one cannot be reached by this rule's signers, so it is not a way around
533
- * this policy; it is a different principal's authority, which no policy
534
- * installed here was ever going to constrain. Other rules keep their own
535
- * signers, and an account administrator can add signers or rules afterwards.
536
- *
537
- * An empty `overlaps` is therefore NOT a statement that the account is safe,
538
- * only that this rule's own signers gain no unconstrained path through the
539
- * rules that exist right now. */
540
- export interface AuthorityScanReport {
541
- /** False when the scan did not run. `reason` then says why, and the absence
542
- * of overlaps proves nothing. */
543
- ran: boolean
544
- /** True when the caller passed `skipAuthorityScan`. Distinguishes a
545
- * deliberate skip from a scan that tried and failed: both carry
546
- * `ran: false`, but only the failure refuses the install. Recorded rather
547
- * than omitted so the response shows that no opinion was formed, instead
548
- * of looking like a version that never had the check. */
549
- skipped?: boolean
550
- /** True when the account has more rules than the scan accounted for, so the
551
- * overlap list is a subset. */
552
- incomplete?: boolean
553
- reason?: string
554
- overlaps: AuthorityOverlap[]
555
- }
556
-
557
- /** Read the account's other context rules and report where this install's
558
- * signers already hold authority over the same calls.
559
- *
560
- * A failure to read is reported rather than thrown, and the caller-facing
561
- * decision is made by `enforceAuthorityScan`: a scan that tried and failed
562
- * tells us nothing, so it refuses rather than passing as a clean account.
563
- *
564
- * The account data this trusts comes from whichever RPC answered, so a
565
- * hostile RPC could describe an account with no overlapping rules. That is
566
- * bounded by the pin already enforced above: `enforceRpcPin` returns before
567
- * this runs, so the URL is the pinned one for the network unless the caller
568
- * explicitly set `allowUnpinnedRpcUrl`. This scan deliberately does not add a
569
- * second pin check, because two places deciding the same thing drift. */
570
- async function scanAuthorityOverlap(args: {
571
- smartAccount: string
572
- interpreterAddress: string
573
- rule: InstallPolicyInput['rule']
574
- encodedPredicate: string
575
- rpcUrl: string
576
- network: Network
577
- }): Promise<AuthorityScanReport | undefined> {
578
- try {
579
- if (!args.encodedPredicate) {
580
- // Not applicable rather than failed: this rule installs no interpreter
581
- // predicate, so there is nothing of ours for another rule to undercut.
582
- // Returning undefined keeps it out of the refusal path, which is
583
- // reserved for scans that were meant to run and could not.
584
- return undefined
585
- }
586
- const server = new rpc.Server(args.rpcUrl, { allowHttp: false })
587
- const reader = accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[args.network])
588
- const collected = await collectObservedRules({
589
- reader,
590
- smartAccount: args.smartAccount,
591
- interpreterAddress: args.interpreterAddress,
592
- })
593
- const overlaps = findAuthorityOverlaps({
594
- intended: {
595
- // `add_context_rule` always creates a NEW rule, so there is no id to
596
- // exclude; -1 matches nothing on the account.
597
- ruleId: -1,
598
- contextType: toOverlapContextType(args.rule.contextRuleType),
599
- signers: args.rule.signers,
600
- predicate: decodePredicate(args.encodedPredicate),
601
- },
602
- existing: collected.rules,
603
- })
604
- return { ran: true, incomplete: collected.incomplete, overlaps }
605
- } catch (e) {
606
- return {
607
- ran: false,
608
- reason: e instanceof Error ? e.message : String(e),
609
- overlaps: [],
610
- }
611
- }
612
- }
613
-
614
- /** The rule draft names the callee `contract`; the analyser calls it
615
- * `address`. Same value, two vocabularies. */
616
- function toOverlapContextType(ct: InstallPolicyInput['rule']['contextRuleType']): ContextType {
617
- switch (ct.kind) {
618
- case 'call_contract':
619
- return { kind: 'call_contract', address: ct.contract }
620
- case 'create_contract':
621
- return { kind: 'create_contract', wasmHash: ct.wasmHash }
622
- default:
623
- return { kind: 'default' }
624
- }
625
- }
626
-
627
- /** The merge response: one step's transaction plus what it will cost. */
628
- export interface MergePolicyResult {
629
- unsignedXdr: string
630
- smartAccount: string
631
- sourceAccount: string
632
- step: 'detach' | 'reinstall'
633
- call: { contract: string; fn: string; ruleId: number }
634
- authNonce: string
635
- authValidUntilLedger: number
636
- rootInvocationXdr: string
637
- /** sha256 of the merged predicate, so the caller can pin what step 2 will
638
- * install while they are still looking at step 1. */
639
- mergedPredicateHash: string
640
- mergedPredicateBlobBase64: string
641
- warnings: string[]
642
- followUp: string
643
- }
644
-
645
- /** `merge_policy` body - the tightening remedy for a cross-rule overlap.
646
- *
647
- * Replaces a rule's predicate with the conjunction of it and a new one. This
648
- * is the action `install_policy` recommends when it reports an overlap
649
- * between two rules our interpreter polices, and it is deliberately NOT
650
- * something `install_policy` does on its own: it detaches a live policy, so
651
- * the operator has to ask for it.
652
- *
653
- * Two transactions in order. `add_policy` refuses a policy already on the
654
- * rule, so the old attachment goes first, and the second transaction cannot
655
- * be simulated until the first confirms. */
656
- export async function runMergePolicy(raw: unknown): Promise<ToolResponse<MergePolicyResult>> {
657
- const parsed = MergePolicyInputSchema.safeParse(raw)
658
- if (!parsed.success) {
659
- return { ok: false, error: validationError('install_policy', parsed.error.issues) }
660
- }
661
- const input: MergePolicyInput = parsed.data
662
- const network: Network = input.network ?? 'testnet'
663
- const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
664
- const expectedRpc = RPC_URL_BY_NETWORK[network]
665
-
666
- const rpcPinningError = enforceRpcPin(
667
- 'install_policy',
668
- input.rpcUrl,
669
- input.allowUnpinnedRpcUrl,
670
- expectedRpc,
671
- network
672
- )
673
- if (rpcPinningError) return { ok: false, error: rpcPinningError }
674
-
675
- try {
676
- const rpcUrl = input.rpcUrl ?? expectedRpc
677
- const server = new rpc.Server(rpcUrl, { allowHttp: false })
678
- const reader = accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network])
679
- const collected = await collectObservedRules({
680
- reader,
681
- smartAccount: input.smartAccount,
682
- interpreterAddress: expectedInterpreter,
683
- })
684
- const rule = collected.rules.find((r) => r.id === input.ruleId)
685
- if (!rule) {
686
- return {
687
- ok: false,
688
- error: {
689
- code: 'INSTALL_BUILD_FAILED',
690
- message: `merge_policy: rule ${input.ruleId} was not found on ${input.smartAccount}${
691
- collected.incomplete
692
- ? ' (the rule scan was incomplete, so it may exist but was not reached)'
693
- : ''
694
- }`,
695
- severity: 'error',
696
- retryable: false,
697
- remediation: { toolCall: { name: 'install_policy', args: {} } },
698
- },
699
- }
700
- }
701
-
702
- const plan = planMergePolicy({
703
- rule,
704
- interpreterAddress: expectedInterpreter,
705
- incoming: decodePredicate(input.incomingPredicateBlobBase64),
706
- step: input.step,
707
- })
708
- if (!plan.ok) {
709
- return {
710
- ok: false,
711
- error: {
712
- code: 'INSTALL_BUILD_FAILED',
713
- message: `merge_policy: ${plan.reason}`,
714
- severity: 'error',
715
- retryable: false,
716
- remediation: { toolCall: { name: 'install_policy', args: {} } },
717
- },
718
- }
719
- }
720
-
721
- const encoded = encodePredicate(plan.predicate)
722
-
723
- // The nonce is read, not assumed. OZ's `remove_policy` discards the result
724
- // of `try_uninstall`, so a detach whose uninstall panicked - our
725
- // `uninstall` panics MissingState when the master set has been archived -
726
- // detaches the policy while leaving our nonce behind. Re-installing at 1
727
- // would then be refused as a replay and the rule would sit unpoliced.
728
- let installNonce = 1
729
- const nonceWarnings: string[] = []
730
- if (input.step === 'reinstall') {
731
- const entries = await server.getLedgerEntries(
732
- nonceLedgerKey(expectedInterpreter, input.smartAccount, input.ruleId)
733
- )
734
- const raw = entries.entries?.[0]?.val
735
- const stored =
736
- raw && raw.switch() === xdr.LedgerEntryType.contractData()
737
- ? raw.contractData().val()
738
- : undefined
739
- if (stored && stored.switch() === xdr.ScValType.scvU32()) {
740
- installNonce = stored.u32() + 1
741
- nonceWarnings.push(
742
- `the previous uninstall did not complete: rule ${input.ruleId} still holds interpreter state at nonce ${stored.u32()}, so this reinstalls at ${installNonce} rather than 1. The rule's counters were NOT reset.`
743
- )
744
- }
745
- }
746
-
747
- const built = await buildMergePolicyXdr({
748
- smartAccount: input.smartAccount,
749
- sourceAccount: input.sourceAccount,
750
- networkPassphrase: NETWORK_PASSPHRASES[network],
751
- ruleId: input.ruleId,
752
- policyId: plan.policyId,
753
- interpreterAddress: expectedInterpreter,
754
- step: input.step,
755
- encodedPredicate: encoded.encodedPredicate,
756
- predicateHash: encoded.predicateHash,
757
- installNonce,
758
- ...(plan.oracleParams ? { oracleParams: plan.oracleParams } : {}),
759
- rpc: rpcClientFromServer(server, NETWORK_PASSPHRASES[network]),
760
- ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
761
- })
762
-
763
- return {
764
- ok: true,
765
- data: {
766
- ...built,
767
- mergedPredicateHash: encoded.predicateHash,
768
- mergedPredicateBlobBase64: encoded.encodedPredicate,
769
- warnings: [...plan.warnings, ...nonceWarnings],
770
- followUp: plan.followUp,
771
- },
772
- }
257
+ return { ok: true, data: result }
773
258
  } catch (e) {
774
- return { ok: false, error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e) }
259
+ return toolFailure('install_policy', e)
775
260
  }
776
261
  }
777
262
 
@@ -789,10 +274,7 @@ export async function runRevokePolicy(
789
274
  ): Promise<ToolResponse<BuildRevokePolicyResult>> {
790
275
  const parsed = RevokePolicyInputSchema.safeParse(raw)
791
276
  if (!parsed.success) {
792
- return {
793
- ok: false,
794
- error: validationError('revoke_policy', parsed.error.issues),
795
- }
277
+ return { ok: false, error: validationError('revoke_policy', parsed.error.issues) }
796
278
  }
797
279
  const input: RevokePolicyInput = parsed.data
798
280
  const network: Network = input.network ?? 'testnet'
@@ -811,10 +293,7 @@ export async function runRevokePolicy(
811
293
  try {
812
294
  rpcClient = buildRpcClientFromInput(input.rpcUrl, network)
813
295
  } catch (e) {
814
- return {
815
- ok: false,
816
- error: caughtError('revoke_policy', 'REVOKE_BUILD_FAILED', e),
817
- }
296
+ return toolFailure('revoke_policy', e)
818
297
  }
819
298
  try {
820
299
  const result = await buildRevokePolicyXdr({
@@ -827,10 +306,7 @@ export async function runRevokePolicy(
827
306
  })
828
307
  return { ok: true, data: result }
829
308
  } catch (e) {
830
- return {
831
- ok: false,
832
- error: caughtError('revoke_policy', 'REVOKE_BUILD_FAILED', e),
833
- }
309
+ return toolFailure('revoke_policy', e)
834
310
  }
835
311
  }
836
312
 
@@ -841,27 +317,126 @@ export async function runRevokePolicy(
841
317
  * fabricating it would be a lie on a security surface; the live
842
318
  * mismatch check is worth MORE).
843
319
  *
844
- * Network-aware: `input.network` selects the interpreter address, the RPC
845
- * and the wasm hash. The networks run different binaries - testnet carries
846
- * the selector-leaf minimum and the signer-set cap, mainnet predates both -
847
- * so the hash is read through
848
- * `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK`. UNAUDITED at the time of
849
- * writing.
320
+ * Network-aware: `input.network` selects which interpreter pin and RPC
321
+ * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
322
+ * uploaded to mainnet as was exercised on testnet, so a single
323
+ * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks.
324
+ * The address differs because instance ids are network-scoped.
325
+ * UNAUDITED at the time of writing.
850
326
  *
851
327
  * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
852
328
  * call, the auth-digest + the answer bind to whichever RPC answered, so
853
329
  * a non-pinned `rpcUrl` would silently bind the caller to a host they
854
330
  * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
855
331
  * opt-in as install/revoke. */
332
+ /** The call a predicate is evaluated against: the single top-level invocation
333
+ * the smart account authorises. `Policy::enforce` receives one `Context`, not
334
+ * a sub-invocation tree, so simulating anything deeper would claim a
335
+ * guarantee the contract does not make. */
336
+ function evalContextFromRecording(tx: RecordedTransaction): EvalContext | null {
337
+ const top = tx.invocations[0]
338
+ if (!top) return null
339
+ return { contract: top.contract, fn: top.fn, args: top.args }
340
+ }
341
+
342
+ /** Both `simulate_policy` and `verify_policy` evaluate against a context derived
343
+ * from `permitTx`; neither has anything to evaluate when the recording carries
344
+ * no top-level invocation. */
345
+ function noInvocationError(toolName: 'simulate_policy' | 'verify_policy'): ToolError {
346
+ return {
347
+ code: TOOL_ERROR_CODE[toolName],
348
+ message: `${toolName}: permitTx carries no invocation to evaluate`,
349
+ severity: 'error',
350
+ retryable: false,
351
+ }
352
+ }
353
+
354
+ /** `simulate_policy` body - evaluate a predicate against one recorded call.
355
+ *
356
+ * The evaluator is a second implementation of the on-chain semantics, and the
357
+ * conformance harness asserts it agrees with the Rust interpreter case for
358
+ * case. A verdict here is therefore a claim about what the contract would do,
359
+ * not a guess. */
360
+ export function runSimulatePolicy(raw: unknown): ToolResponse<{
361
+ permitted: boolean
362
+ reason: string | null
363
+ call: { contract: string; fn: string; argCount: number }
364
+ }> {
365
+ const parsed = SimulatePolicyInputSchema.safeParse(raw)
366
+ if (!parsed.success) {
367
+ return { ok: false, error: validationError('simulate_policy', parsed.error.issues) }
368
+ }
369
+ const input: SimulatePolicyInput = parsed.data
370
+ const ctx = evalContextFromRecording(input.permitTx as RecordedTransaction)
371
+ if (!ctx) return { ok: false, error: noInvocationError('simulate_policy') }
372
+ try {
373
+ const res = evaluate(input.predicate as PredicateNode, ctx)
374
+ return {
375
+ ok: true,
376
+ data: {
377
+ permitted: res.permit,
378
+ reason: res.permit ? null : res.reason,
379
+ call: { contract: ctx.contract, fn: ctx.fn, argCount: ctx.args.length },
380
+ },
381
+ }
382
+ } catch (e) {
383
+ return toolFailure('simulate_policy', e)
384
+ }
385
+ }
386
+
387
+ /** `verify_policy` body - the permit case plus a generated deny case per
388
+ * dimension.
389
+ *
390
+ * Two failure modes are being checked, and they are not the same thing. A
391
+ * permit case that denies means the policy is too STRICT: it would refuse the
392
+ * very transaction it was synthesised from. A deny case that permits means it
393
+ * is too LOOSE: some mutation of that transaction still gets through. `ok` is
394
+ * true only when neither holds. */
395
+ export function runVerifyPolicy(raw: unknown): ToolResponse<{
396
+ ok: boolean
397
+ permit: { permitted: boolean; reason: string | null }
398
+ denies: Array<{ dimension: string; denied: boolean; reason: string | null }>
399
+ dimensionsCovered: number
400
+ }> {
401
+ const parsed = VerifyPolicyInputSchema.safeParse(raw)
402
+ if (!parsed.success) {
403
+ return { ok: false, error: validationError('verify_policy', parsed.error.issues) }
404
+ }
405
+ const input: VerifyPolicyInput = parsed.data
406
+ const ctx = evalContextFromRecording(input.permitTx as RecordedTransaction)
407
+ if (!ctx) return { ok: false, error: noInvocationError('verify_policy') }
408
+ try {
409
+ const predicate = input.predicate as PredicateNode
410
+ const cases = generateCases(predicate, ctx)
411
+ const permitRes = evaluate(predicate, cases.permit)
412
+ const denies = cases.denies.map((d) => {
413
+ const r = evaluate(predicate, d.ctx)
414
+ return {
415
+ dimension: d.dimension,
416
+ denied: !r.permit,
417
+ reason: r.permit ? null : r.reason,
418
+ }
419
+ })
420
+ return {
421
+ ok: true,
422
+ data: {
423
+ ok: permitRes.permit && denies.every((d) => d.denied),
424
+ permit: { permitted: permitRes.permit, reason: permitRes.permit ? null : permitRes.reason },
425
+ denies,
426
+ dimensionsCovered: denies.length,
427
+ },
428
+ }
429
+ } catch (e) {
430
+ return toolFailure('verify_policy', e)
431
+ }
432
+ }
433
+
856
434
  export async function runGetInterpreterInfo(
857
435
  raw: unknown
858
436
  ): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>> {
859
437
  const parsed = GetInterpreterInfoInputSchema.safeParse(raw)
860
438
  if (!parsed.success) {
861
- return {
862
- ok: false,
863
- error: validationError('get_interpreter_info', parsed.error.issues),
864
- }
439
+ return { ok: false, error: validationError('get_interpreter_info', parsed.error.issues) }
865
440
  }
866
441
  const input: GetInterpreterInfoInput = parsed.data
867
442
  const network: Network = input.network ?? 'testnet'
@@ -894,16 +469,13 @@ export async function runGetInterpreterInfo(
894
469
  const info = getInterpreterInfo({
895
470
  pinnedAddress,
896
471
  pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
897
- pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK[network],
472
+ pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256,
898
473
  network,
899
474
  ...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
900
475
  })
901
476
  return { ok: true, data: info }
902
477
  } catch (e) {
903
- return {
904
- ok: false,
905
- error: caughtError('get_interpreter_info', 'RECORDING_FAILED', e),
906
- }
478
+ return toolFailure('get_interpreter_info', e)
907
479
  }
908
480
  }
909
481
 
@@ -969,14 +541,8 @@ function enforceRpcPin(
969
541
  network: Network
970
542
  ): ToolError | null {
971
543
  if (!rpcUrl || rpcUrl === expectedRpc || allowUnpinned === true) return null
972
- const code: ErrorCode =
973
- toolName === 'install_policy'
974
- ? 'INSTALL_BUILD_FAILED'
975
- : toolName === 'revoke_policy'
976
- ? 'REVOKE_BUILD_FAILED'
977
- : 'RECORDING_FAILED'
978
544
  return {
979
- code,
545
+ code: TOOL_ERROR_CODE[toolName],
980
546
  message: `${toolName}: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
981
547
  severity: 'error',
982
548
  retryable: false,
@@ -1008,6 +574,12 @@ function validationError(
1008
574
  * error in `details` for the agent to inspect. Exported as a test-only seam
1009
575
  * so the suite in run/index.test.ts can drive the envelope path without
1010
576
  * standing up a full recordTransaction pipeline. */
577
+ /** The fail-closed return every tool body uses in its `catch`. Taking only the
578
+ * tool name keeps the tool -> error-code mapping in TOOL_ERROR_CODE alone. */
579
+ function toolFailure(toolName: RunToolName, e: unknown): { ok: false; error: ToolError } {
580
+ return { ok: false, error: caughtError(toolName, TOOL_ERROR_CODE[toolName], e) }
581
+ }
582
+
1011
583
  export function caughtError(toolName: RunToolName, code: ErrorCode, e: unknown): ToolError {
1012
584
  return {
1013
585
  code,