@crediolabs/policy-synth 0.2.0 → 0.3.0

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