@crediolabs/policy-synth 0.1.17 → 0.2.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 (113) hide show
  1. package/README.md +44 -246
  2. package/dist/adapters/interpreter/adapter.d.ts +6 -7
  3. package/dist/adapters/interpreter/adapter.js +28 -48
  4. package/dist/adapters/oz/adapter.js +12 -13
  5. package/dist/codegen/compile-gate.js +5 -0
  6. package/dist/codegen/template.js +17 -0
  7. package/dist/install/authority-overlap.d.ts +134 -0
  8. package/dist/install/authority-overlap.js +0 -0
  9. package/dist/install/build-add-context-rule.d.ts +8 -0
  10. package/dist/install/build-add-context-rule.js +17 -46
  11. package/dist/install/build-install-policy.d.ts +12 -5
  12. package/dist/install/build-install-policy.js +32 -23
  13. package/dist/install/build-merge-policy.d.ts +70 -0
  14. package/dist/install/build-merge-policy.js +130 -0
  15. package/dist/install/get-interpreter-info.js +2 -2
  16. package/dist/install/index.d.ts +3 -1
  17. package/dist/install/index.js +8 -1
  18. package/dist/install/oz-auth.js +5 -3
  19. package/dist/install/plan-merge-policy.d.ts +49 -0
  20. package/dist/install/plan-merge-policy.js +86 -0
  21. package/dist/install/read-account-rules.d.ts +100 -0
  22. package/dist/install/read-account-rules.js +283 -0
  23. package/dist/predicate/decode.js +1 -1
  24. package/dist/predicate/encode.js +171 -10
  25. package/dist/record/decode.js +5 -6
  26. package/dist/registry/protocols.d.ts +1 -1
  27. package/dist/registry/protocols.js +1 -1
  28. package/dist/review-card/builder.d.ts +6 -0
  29. package/dist/review-card/builder.js +9 -1
  30. package/dist/run/index.d.ts +104 -18
  31. package/dist/run/index.js +359 -75
  32. package/dist/run/schemas.d.ts +479 -18
  33. package/dist/run/schemas.js +132 -24
  34. package/dist/synth/compose-from-recording.d.ts +10 -15
  35. package/dist/synth/compose-from-recording.js +79 -124
  36. package/dist/synth/deny-cases.d.ts +5 -0
  37. package/dist/synth/deny-cases.js +52 -13
  38. package/dist/synth/evaluate.js +69 -119
  39. package/dist/synth/harness.d.ts +13 -1
  40. package/dist/synth/harness.js +22 -1
  41. package/dist/synth/index.d.ts +1 -1
  42. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  43. package/dist/synth/synthesize-from-recording.js +129 -155
  44. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  45. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  46. package/dist-cjs/adapters/oz/adapter.js +12 -13
  47. package/dist-cjs/codegen/compile-gate.js +5 -0
  48. package/dist-cjs/codegen/template.js +17 -0
  49. package/dist-cjs/install/authority-overlap.d.ts +134 -0
  50. package/dist-cjs/install/authority-overlap.js +0 -0
  51. package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
  52. package/dist-cjs/install/build-add-context-rule.js +17 -45
  53. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  54. package/dist-cjs/install/build-install-policy.js +32 -23
  55. package/dist-cjs/install/build-merge-policy.d.ts +70 -0
  56. package/dist-cjs/install/build-merge-policy.js +134 -0
  57. package/dist-cjs/install/get-interpreter-info.js +2 -2
  58. package/dist-cjs/install/index.d.ts +3 -1
  59. package/dist-cjs/install/index.js +24 -3
  60. package/dist-cjs/install/oz-auth.js +5 -3
  61. package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
  62. package/dist-cjs/install/plan-merge-policy.js +90 -0
  63. package/dist-cjs/install/read-account-rules.d.ts +100 -0
  64. package/dist-cjs/install/read-account-rules.js +296 -0
  65. package/dist-cjs/predicate/decode.js +1 -1
  66. package/dist-cjs/predicate/encode.js +171 -10
  67. package/dist-cjs/record/decode.js +5 -6
  68. package/dist-cjs/registry/protocols.d.ts +1 -1
  69. package/dist-cjs/registry/protocols.js +1 -1
  70. package/dist-cjs/review-card/builder.d.ts +6 -0
  71. package/dist-cjs/review-card/builder.js +9 -1
  72. package/dist-cjs/run/index.d.ts +104 -18
  73. package/dist-cjs/run/index.js +360 -74
  74. package/dist-cjs/run/schemas.d.ts +479 -18
  75. package/dist-cjs/run/schemas.js +133 -25
  76. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  77. package/dist-cjs/synth/compose-from-recording.js +79 -124
  78. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  79. package/dist-cjs/synth/deny-cases.js +52 -13
  80. package/dist-cjs/synth/evaluate.js +69 -119
  81. package/dist-cjs/synth/harness.d.ts +13 -1
  82. package/dist-cjs/synth/harness.js +22 -1
  83. package/dist-cjs/synth/index.d.ts +1 -1
  84. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  85. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  86. package/package.json +1 -1
  87. package/src/adapters/interpreter/adapter.ts +28 -48
  88. package/src/adapters/oz/adapter.ts +12 -13
  89. package/src/codegen/compile-gate.ts +5 -0
  90. package/src/codegen/template.ts +17 -0
  91. package/src/install/authority-overlap.ts +0 -0
  92. package/src/install/build-add-context-rule.ts +29 -56
  93. package/src/install/build-install-policy.ts +41 -23
  94. package/src/install/build-merge-policy.ts +219 -0
  95. package/src/install/get-interpreter-info.ts +2 -2
  96. package/src/install/index.ts +36 -2
  97. package/src/install/oz-auth.ts +5 -3
  98. package/src/install/plan-merge-policy.ts +133 -0
  99. package/src/install/read-account-rules.ts +376 -0
  100. package/src/predicate/decode.ts +1 -1
  101. package/src/predicate/encode.ts +176 -10
  102. package/src/record/decode.ts +5 -6
  103. package/src/registry/protocols.ts +1 -1
  104. package/src/review-card/builder.ts +17 -1
  105. package/src/run/index.ts +500 -102
  106. package/src/run/schemas.ts +145 -24
  107. package/src/synth/compose-from-recording.ts +87 -132
  108. package/src/synth/deny-cases.ts +59 -13
  109. package/src/synth/evaluate.ts +70 -118
  110. package/src/synth/harness.ts +29 -1
  111. package/src/synth/index.ts +1 -0
  112. package/src/synth/synthesize-from-recording.ts +209 -192
  113. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
package/dist/run/index.js CHANGED
@@ -17,17 +17,35 @@
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 } from '@stellar/stellar-sdk';
20
+ import { rpc, xdr } from '@stellar/stellar-sdk';
21
21
  import { placeholderOzConfig, recordTransaction, synthesizeFromMandate, synthesizeFromRecording, } from "../index.js";
22
+ import { findAuthorityOverlaps, } from "../install/authority-overlap.js";
22
23
  import { buildInstallPolicyXdr, buildRevokePolicyXdr, rpcClientFromServer, } from "../install/build-install-policy.js";
24
+ import { buildMergePolicyXdr } from "../install/build-merge-policy.js";
23
25
  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";
24
30
  import { simulatePolicy, verifyPolicy } from "../verify/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";
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";
26
32
  // Re-export the underlying Zod schemas so the MCP package (and any other
27
33
  // downstream consumer) can import the canonical input shapes from the same
28
34
  // module that owns the tool-body glue. The strict schemas are the source of
29
35
  // truth - MCP tool shapes are derived from them.
30
- 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, PredicateLeafSchema, PredicateNodeSchema, RecordedTransactionSchema, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, TESTNET_RPC_URL, ToolErrorSchema, VerifyPolicyInputSchema, } from "./schemas.js";
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";
37
+ /** Map every tool name to its canonical domain error code. Replaces a 7-way
38
+ * if/else so adding a tool adds one line here rather than a new branch in
39
+ * each envelope call. */
40
+ const TOOL_ERROR_CODE = {
41
+ record_transaction: 'RECORDING_FAILED',
42
+ synthesize_policy: 'SYNTHESIS_ERROR',
43
+ simulate_policy: 'SIMULATION_ERROR',
44
+ verify_policy: 'VERIFICATION_FAILED',
45
+ install_policy: 'INSTALL_BUILD_FAILED',
46
+ revoke_policy: 'REVOKE_BUILD_FAILED',
47
+ get_interpreter_info: 'RECORDING_FAILED',
48
+ };
31
49
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
32
50
  * matches the core RecordInput minus the injected `fetcher` (the transport
33
51
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -222,17 +240,9 @@ export async function runInstallPolicy(raw) {
222
240
  if (pinningError) {
223
241
  return { ok: false, error: pinningError };
224
242
  }
225
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
226
- return {
227
- ok: false,
228
- error: {
229
- code: 'INSTALL_BUILD_FAILED',
230
- message: `install_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
231
- severity: 'error',
232
- retryable: false,
233
- remediation: { toolCall: { name: 'install_policy', args: {} } },
234
- },
235
- };
243
+ const rpcPinningError = enforceRpcPin('install_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
244
+ if (rpcPinningError) {
245
+ return { ok: false, error: rpcPinningError };
236
246
  }
237
247
  let rpcClient;
238
248
  try {
@@ -250,6 +260,25 @@ export async function runInstallPolicy(raw) {
250
260
  const predicateHash = createHash('sha256')
251
261
  .update(Buffer.from(encodedPredicate, 'base64'))
252
262
  .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
+ }
253
282
  const result = await buildInstallPolicyXdr({
254
283
  smartAccount: input.smartAccount,
255
284
  sourceAccount: input.sourceAccount,
@@ -261,7 +290,7 @@ export async function runInstallPolicy(raw) {
261
290
  rpc: rpcClient,
262
291
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
263
292
  });
264
- return { ok: true, data: result };
293
+ return { ok: true, data: authorityScan ? { ...result, authorityScan } : result };
265
294
  }
266
295
  catch (e) {
267
296
  return {
@@ -270,6 +299,252 @@ export async function runInstallPolicy(raw) {
270
299
  };
271
300
  }
272
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
+ };
543
+ }
544
+ catch (e) {
545
+ return { ok: false, error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e) };
546
+ }
547
+ }
273
548
  /** `revoke_policy` body - thin wrapper over `buildRevokePolicyXdr`.
274
549
  * Emits an unsigned XDR for `account.remove_context_rule(ruleId)`; the
275
550
  * smart account itself handles uninstalling each attached policy. Auth
@@ -290,17 +565,9 @@ export async function runRevokePolicy(raw) {
290
565
  const input = parsed.data;
291
566
  const network = input.network ?? 'testnet';
292
567
  const expectedRpc = RPC_URL_BY_NETWORK[network];
293
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
294
- return {
295
- ok: false,
296
- error: {
297
- code: 'REVOKE_BUILD_FAILED',
298
- message: `revoke_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
299
- severity: 'error',
300
- retryable: false,
301
- remediation: { toolCall: { name: 'revoke_policy', args: {} } },
302
- },
303
- };
568
+ const rpcPinningError = enforceRpcPin('revoke_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
569
+ if (rpcPinningError) {
570
+ return { ok: false, error: rpcPinningError };
304
571
  }
305
572
  let rpcClient;
306
573
  try {
@@ -337,13 +604,18 @@ export async function runRevokePolicy(raw) {
337
604
  * fabricating it would be a lie on a security surface; the live
338
605
  * mismatch check is worth MORE).
339
606
  *
340
- * Network-aware: `input.network` selects which interpreter pin and RPC
341
- * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
342
- * uploaded to mainnet as was exercised on testnet, so a single
343
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks
344
- * (DEPLOYMENTS.md:25-27). The address differs because instance ids are
345
- * network-scoped. UNAUDITED at the time of writing - see
346
- * DEPLOYMENTS.md:29-32 for what is still pending. */
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.
613
+ *
614
+ * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
615
+ * call, the auth-digest + the answer bind to whichever RPC answered, so
616
+ * a non-pinned `rpcUrl` would silently bind the caller to a host they
617
+ * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
618
+ * opt-in as install/revoke. */
347
619
  export async function runGetInterpreterInfo(raw) {
348
620
  const parsed = GetInterpreterInfoInputSchema.safeParse(raw);
349
621
  if (!parsed.success) {
@@ -354,6 +626,19 @@ export async function runGetInterpreterInfo(raw) {
354
626
  }
355
627
  const input = parsed.data;
356
628
  const network = input.network ?? 'testnet';
629
+ const expectedRpc = RPC_URL_BY_NETWORK[network];
630
+ // Pin enforcement only matters when the live RPC call is engaged. The
631
+ // static-fingerprint branch is local data and a non-pinned URL never
632
+ // reaches the network, so the gate is conditioned on `verifyLive` to
633
+ // match the wire-level concern (an outbound request that returns a
634
+ // caller-bound value). A caller that DOES NOT verifyLive can supply any
635
+ // URL it likes; the field is still optional.
636
+ if (input.verifyLive === true) {
637
+ const rpcPinningError = enforceRpcPin('get_interpreter_info', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
638
+ if (rpcPinningError) {
639
+ return { ok: false, error: rpcPinningError };
640
+ }
641
+ }
357
642
  const pinnedAddress = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
358
643
  try {
359
644
  let deployedGrammarVersion;
@@ -364,7 +649,7 @@ export async function runGetInterpreterInfo(raw) {
364
649
  const info = getInterpreterInfo({
365
650
  pinnedAddress,
366
651
  pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
367
- pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256,
652
+ pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK[network],
368
653
  network,
369
654
  ...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
370
655
  });
@@ -391,15 +676,14 @@ function buildRpcClientFromInput(urlOverride, network) {
391
676
  // needs the full Server surface.
392
677
  return rpcClientFromServer(new rpc.Server(url, { allowHttp: false }), passphrase);
393
678
  }
394
- /** Default-deny: refuse any interpreter policy whose address differs from
395
- * the pinned interpreter for the selected network. An interpreter the
396
- * caller controls can permit anything, so the smart account's
397
- * authorization must bind to the pinned contract unless the caller
398
- * explicitly opts in via `allowUnpinnedInterpreter`. OZ built-in
399
- * policies are not interpreters and pass through unchanged. Returns a
400
- * ToolError to surface through the run-layer envelope, or null when the
401
- * policies are all pinned. The caller resolves the expected pin per
402
- * network; this function stays pure so it is easy to test. */
679
+ /** Default-deny: refuse an interpreter policy whose address differs from the
680
+ * pinned interpreter for the selected network. An interpreter the caller
681
+ * controls can permit anything, so the smart account's authorization must
682
+ * bind to the pinned contract unless the caller opts in. OZ built-in
683
+ * policies are not interpreters and pass through. Returns a ToolError to
684
+ * surface through the run-layer envelope, or null when all interpreter
685
+ * policies are pinned. The caller resolves the expected pin per network;
686
+ * this function stays pure so it is easy to test. */
403
687
  function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddress) {
404
688
  for (const p of policies) {
405
689
  if (p.kind !== 'interpreter')
@@ -418,58 +702,58 @@ function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddre
418
702
  }
419
703
  return null;
420
704
  }
705
+ /** Default-deny: refuse an `rpcUrl` that is not the pinned RPC for the
706
+ * selected network. The auth nonce + rootInvocation in the install/revoke
707
+ * response come from whichever RPC answered, so a non-pinned RPC would
708
+ * silently bind the caller to a host they picked. The same applies to
709
+ * `get_interpreter_info` when `verifyLive` is true (the live grammar
710
+ * version is the caller-bound value). The caller may opt in via
711
+ * `allowUnpinnedRpcUrl: true`. Returns a ToolError or null when the URL is
712
+ * pinned (or absent, since the default is the pinned one). */
713
+ function enforceRpcPin(toolName, rpcUrl, allowUnpinned, expectedRpc, network) {
714
+ if (!rpcUrl || rpcUrl === expectedRpc || allowUnpinned === true)
715
+ return null;
716
+ const code = toolName === 'install_policy'
717
+ ? 'INSTALL_BUILD_FAILED'
718
+ : toolName === 'revoke_policy'
719
+ ? 'REVOKE_BUILD_FAILED'
720
+ : 'RECORDING_FAILED';
721
+ return {
722
+ code,
723
+ message: `${toolName}: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
724
+ severity: 'error',
725
+ retryable: false,
726
+ remediation: { toolCall: { name: toolName, args: {} } },
727
+ };
728
+ }
421
729
  /** Build a canonical ToolError for a Zod validation failure. The remediation
422
730
  * hint points the agent back at the right tool with an empty arg bag - the
423
731
  * tool name IS the machine-readable hint. */
424
732
  function validationError(toolName, issues) {
425
- const code = toolName === 'record_transaction'
426
- ? 'RECORDING_FAILED'
427
- : toolName === 'synthesize_policy'
428
- ? 'SYNTHESIS_ERROR'
429
- : toolName === 'simulate_policy'
430
- ? 'SIMULATION_ERROR'
431
- : toolName === 'verify_policy'
432
- ? 'VERIFICATION_FAILED'
433
- : toolName === 'install_policy'
434
- ? 'INSTALL_BUILD_FAILED'
435
- : toolName === 'revoke_policy'
436
- ? 'REVOKE_BUILD_FAILED'
437
- : 'RECORDING_FAILED';
438
733
  return {
439
- code,
734
+ code: TOOL_ERROR_CODE[toolName],
440
735
  message: `${toolName}: invalid input: ${issues
441
736
  .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
442
737
  .join('; ')}`,
443
738
  severity: 'error',
444
739
  retryable: false,
445
- remediation: {
446
- toolCall: { name: toolName, args: {} },
447
- },
740
+ remediation: { toolCall: { name: toolName, args: {} } },
448
741
  };
449
742
  }
450
743
  /** Build a canonical ToolError for a thrown exception caught by the tool
451
744
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
452
745
  * default, so we extract a string-friendly message and tag the original
453
- * error in `details` for the agent to inspect. The `code` is the tool's
454
- * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
455
- * for `synthesize_policy`) so the agent dispatches on the same code the
456
- * structured ToolError would carry.
457
- *
458
- * Exported as `_caughtError` (the leading underscore signals the test-only
459
- * seam) so the suite in run/index.test.ts can drive the envelope path
460
- * without standing up a full recordTransaction pipeline. */
746
+ * error in `details` for the agent to inspect. Exported as a test-only seam
747
+ * so the suite in run/index.test.ts can drive the envelope path without
748
+ * standing up a full recordTransaction pipeline. */
461
749
  export function caughtError(toolName, code, e) {
462
- const message = describeThrown(e, toolName);
463
- const details = { thrown: safeStringify(e) };
464
750
  return {
465
751
  code,
466
- message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
752
+ message: `${toolName}: unhandled throw escaped core envelope: ${describeThrown(e, toolName)}`,
467
753
  severity: 'error',
468
754
  retryable: false,
469
- remediation: {
470
- toolCall: { name: toolName, args: {} },
471
- },
472
- details,
755
+ remediation: { toolCall: { name: toolName, args: {} } },
756
+ details: { thrown: safeStringify(e) },
473
757
  };
474
758
  }
475
759
  /** Build a human-readable message for an unknown caught value. Order matters: