@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
@@ -18,20 +18,28 @@
18
18
  // No business logic. No retries. No session state. The same call shape can
19
19
  // drive the CLI (which calls into the same core directly without MCP).
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.VerifyPolicyInputSchema = exports.ToolErrorSchema = exports.TESTNET_RPC_URL = exports.SynthesizePolicyInputSchema = exports.SimulatePolicyInputSchema = exports.RPC_URL_BY_NETWORK = exports.RevokePolicyInputSchema = exports.RecordTransactionInputSchema = exports.RecordedTransactionSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.PINNED_INTERPRETER_WASM_SHA256 = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.OzAdapterConfigSchema = exports.OraclePriceFixtureSchema = exports.NetworkSchema = exports.MandateSpecSchema = exports.MAINNET_RPC_URL = exports.InterpreterOptionsSchema = exports.InstallPolicyInputSchema = exports.GetInterpreterInfoInputSchema = exports.ComposeUserResponsesSchema = void 0;
21
+ exports.VerifyPolicyInputSchema = exports.ToolErrorSchema = exports.TESTNET_RPC_URL = exports.SynthesizePolicyInputSchema = exports.SimulatePolicyInputSchema = exports.RPC_URL_BY_NETWORK = exports.RevokePolicyInputSchema = exports.RecordTransactionInputSchema = exports.RecordedTransactionSchema = exports.PredicateNodeSchema = exports.PredicateLeafSchema = exports.PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK = exports.PINNED_INTERPRETER_TESTNET_ADDRESS = exports.PINNED_INTERPRETER_MAINNET_ADDRESS = exports.PINNED_INTERPRETER_GRAMMAR_VERSION = exports.PINNED_INTERPRETER_ADDRESS_BY_NETWORK = exports.OzAdapterConfigSchema = exports.OraclePriceFixtureSchema = exports.NetworkSchema = exports.MandateSpecSchema = exports.MAINNET_RPC_URL = exports.InterpreterOptionsSchema = exports.InstallPolicyInputSchema = exports.GetInterpreterInfoInputSchema = exports.ComposeUserResponsesSchema = void 0;
22
22
  exports.runRecordTransaction = runRecordTransaction;
23
23
  exports.runSynthesizePolicy = runSynthesizePolicy;
24
24
  exports.runSimulatePolicy = runSimulatePolicy;
25
25
  exports.runVerifyPolicy = runVerifyPolicy;
26
26
  exports.runInstallPolicy = runInstallPolicy;
27
+ exports.enforceAuthorityScan = enforceAuthorityScan;
28
+ exports.runMergePolicy = runMergePolicy;
27
29
  exports.runRevokePolicy = runRevokePolicy;
28
30
  exports.runGetInterpreterInfo = runGetInterpreterInfo;
29
31
  exports.caughtError = caughtError;
30
32
  const node_crypto_1 = require("node:crypto");
31
33
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
32
34
  const index_ts_1 = require("../index.js");
35
+ const authority_overlap_ts_1 = require("../install/authority-overlap.js");
33
36
  const build_install_policy_ts_1 = require("../install/build-install-policy.js");
37
+ const build_merge_policy_ts_1 = require("../install/build-merge-policy.js");
34
38
  const get_interpreter_info_ts_1 = require("../install/get-interpreter-info.js");
39
+ const plan_merge_policy_ts_1 = require("../install/plan-merge-policy.js");
40
+ const read_account_rules_ts_1 = require("../install/read-account-rules.js");
41
+ const decode_ts_1 = require("../predicate/decode.js");
42
+ const encode_ts_1 = require("../predicate/encode.js");
35
43
  const index_ts_2 = require("../verify/index.js");
36
44
  const schemas_ts_1 = require("./schemas.js");
37
45
  // Re-export the underlying Zod schemas so the MCP package (and any other
@@ -52,7 +60,7 @@ Object.defineProperty(exports, "PINNED_INTERPRETER_ADDRESS_BY_NETWORK", { enumer
52
60
  Object.defineProperty(exports, "PINNED_INTERPRETER_GRAMMAR_VERSION", { enumerable: true, get: function () { return schemas_ts_2.PINNED_INTERPRETER_GRAMMAR_VERSION; } });
53
61
  Object.defineProperty(exports, "PINNED_INTERPRETER_MAINNET_ADDRESS", { enumerable: true, get: function () { return schemas_ts_2.PINNED_INTERPRETER_MAINNET_ADDRESS; } });
54
62
  Object.defineProperty(exports, "PINNED_INTERPRETER_TESTNET_ADDRESS", { enumerable: true, get: function () { return schemas_ts_2.PINNED_INTERPRETER_TESTNET_ADDRESS; } });
55
- Object.defineProperty(exports, "PINNED_INTERPRETER_WASM_SHA256", { enumerable: true, get: function () { return schemas_ts_2.PINNED_INTERPRETER_WASM_SHA256; } });
63
+ Object.defineProperty(exports, "PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK", { enumerable: true, get: function () { return schemas_ts_2.PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK; } });
56
64
  Object.defineProperty(exports, "PredicateLeafSchema", { enumerable: true, get: function () { return schemas_ts_2.PredicateLeafSchema; } });
57
65
  Object.defineProperty(exports, "PredicateNodeSchema", { enumerable: true, get: function () { return schemas_ts_2.PredicateNodeSchema; } });
58
66
  Object.defineProperty(exports, "RecordedTransactionSchema", { enumerable: true, get: function () { return schemas_ts_2.RecordedTransactionSchema; } });
@@ -64,6 +72,18 @@ Object.defineProperty(exports, "SynthesizePolicyInputSchema", { enumerable: true
64
72
  Object.defineProperty(exports, "TESTNET_RPC_URL", { enumerable: true, get: function () { return schemas_ts_2.TESTNET_RPC_URL; } });
65
73
  Object.defineProperty(exports, "ToolErrorSchema", { enumerable: true, get: function () { return schemas_ts_2.ToolErrorSchema; } });
66
74
  Object.defineProperty(exports, "VerifyPolicyInputSchema", { enumerable: true, get: function () { return schemas_ts_2.VerifyPolicyInputSchema; } });
75
+ /** Map every tool name to its canonical domain error code. Replaces a 7-way
76
+ * if/else so adding a tool adds one line here rather than a new branch in
77
+ * each envelope call. */
78
+ const TOOL_ERROR_CODE = {
79
+ record_transaction: 'RECORDING_FAILED',
80
+ synthesize_policy: 'SYNTHESIS_ERROR',
81
+ simulate_policy: 'SIMULATION_ERROR',
82
+ verify_policy: 'VERIFICATION_FAILED',
83
+ install_policy: 'INSTALL_BUILD_FAILED',
84
+ revoke_policy: 'REVOKE_BUILD_FAILED',
85
+ get_interpreter_info: 'RECORDING_FAILED',
86
+ };
67
87
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
68
88
  * matches the core RecordInput minus the injected `fetcher` (the transport
69
89
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -258,17 +278,9 @@ async function runInstallPolicy(raw) {
258
278
  if (pinningError) {
259
279
  return { ok: false, error: pinningError };
260
280
  }
261
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
262
- return {
263
- ok: false,
264
- error: {
265
- code: 'INSTALL_BUILD_FAILED',
266
- message: `install_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
267
- severity: 'error',
268
- retryable: false,
269
- remediation: { toolCall: { name: 'install_policy', args: {} } },
270
- },
271
- };
281
+ const rpcPinningError = enforceRpcPin('install_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
282
+ if (rpcPinningError) {
283
+ return { ok: false, error: rpcPinningError };
272
284
  }
273
285
  let rpcClient;
274
286
  try {
@@ -286,6 +298,25 @@ async function runInstallPolicy(raw) {
286
298
  const predicateHash = (0, node_crypto_1.createHash)('sha256')
287
299
  .update(Buffer.from(encodedPredicate, 'base64'))
288
300
  .digest('hex');
301
+ // ---- Cross-rule authority scan ----
302
+ // OZ enforces only the policies of the rule the caller names, so a signer
303
+ // who also sits in a wider rule keeps that wider authority no matter what
304
+ // this predicate says. Refuse by default when the wider rule has no policy
305
+ // at all, because that makes this install decorative.
306
+ const authorityScan = input.skipAuthorityScan
307
+ ? { ran: false, skipped: true, reason: 'skipped at caller request', overlaps: [] }
308
+ : await scanAuthorityOverlap({
309
+ smartAccount: input.smartAccount,
310
+ interpreterAddress: expectedInterpreter,
311
+ rule: input.rule,
312
+ encodedPredicate,
313
+ rpcUrl: input.rpcUrl ?? expectedRpc,
314
+ network,
315
+ });
316
+ const overlapError = enforceAuthorityScan(authorityScan, input.allowAuthorityOverlap);
317
+ if (overlapError) {
318
+ return { ok: false, error: overlapError };
319
+ }
289
320
  const result = await (0, build_install_policy_ts_1.buildInstallPolicyXdr)({
290
321
  smartAccount: input.smartAccount,
291
322
  sourceAccount: input.sourceAccount,
@@ -297,7 +328,7 @@ async function runInstallPolicy(raw) {
297
328
  rpc: rpcClient,
298
329
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
299
330
  });
300
- return { ok: true, data: result };
331
+ return { ok: true, data: authorityScan ? { ...result, authorityScan } : result };
301
332
  }
302
333
  catch (e) {
303
334
  return {
@@ -306,6 +337,252 @@ async function runInstallPolicy(raw) {
306
337
  };
307
338
  }
308
339
  }
340
+ /** Default-deny on the cross-rule scan.
341
+ *
342
+ * Refuses whenever the scan cannot establish that this policy binds the calls
343
+ * it names. An unpoliced neighbour provably does not constrain them. An
344
+ * opaque one, policed by a contract this tool cannot decode, is not KNOWN to,
345
+ * and "not known to" is not "safe" - the same posture as the interpreter and
346
+ * RPC pins. An incomplete scan is refused for the same reason: the overlap
347
+ * list is then a subset of the account, so an empty list proves nothing.
348
+ *
349
+ * `not-restricting` is reported but does NOT block. Both rules are ours and
350
+ * both constrain the calls, and the conjunction remedy is offered; refusing
351
+ * there would also block the legitimate act of adding a separate capability,
352
+ * which OZ composes correctly as a union.
353
+ *
354
+ * Returns a ToolError or null, matching `enforceInterpreterPin`. */
355
+ function enforceAuthorityScan(scan, allowOverlap) {
356
+ if (!scan || allowOverlap === true)
357
+ return null;
358
+ // Default-deny by exclusion rather than by enumeration: anything that is not
359
+ // the one known-safe severity blocks. Listing the blocking severities
360
+ // instead would mean a severity added later silently passes until someone
361
+ // remembers to add it here, and the safe direction is the opposite.
362
+ const blocking = scan.overlaps.filter((o) => o.severity !== 'not-restricting');
363
+ const unpoliced = blocking.filter((o) => o.severity === 'bypass').map((o) => o.ruleId);
364
+ const opaque = blocking.filter((o) => o.severity === 'unknown').map((o) => o.ruleId);
365
+ const unrecognised = blocking
366
+ .filter((o) => o.severity !== 'bypass' && o.severity !== 'unknown')
367
+ .map((o) => o.ruleId);
368
+ if (blocking.length > 0) {
369
+ const parts = [
370
+ unpoliced.length > 0 ? `rule ${unpoliced.join(', ')} has no policy attached` : '',
371
+ opaque.length > 0
372
+ ? `rule ${opaque.join(', ')} is policed by a contract this tool cannot decode`
373
+ : '',
374
+ unrecognised.length > 0
375
+ ? `rule ${unrecognised.join(', ')} carries an overlap this build does not recognise`
376
+ : '',
377
+ ].filter(Boolean);
378
+ return {
379
+ code: 'INSTALL_BUILD_FAILED',
380
+ 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`,
381
+ severity: 'error',
382
+ retryable: false,
383
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
384
+ };
385
+ }
386
+ // A scan that was meant to run and threw tells us nothing. Reporting it in
387
+ // a string field and proceeding is the same fail-open shape as an incomplete
388
+ // scan, and a caller that does not read `ran` cannot tell it from a clean
389
+ // result.
390
+ if (scan.ran === false && scan.skipped !== true) {
391
+ return {
392
+ code: 'INSTALL_BUILD_FAILED',
393
+ 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`,
394
+ severity: 'error',
395
+ retryable: true,
396
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
397
+ };
398
+ }
399
+ if (scan.incomplete === true) {
400
+ return {
401
+ code: 'INSTALL_BUILD_FAILED',
402
+ 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',
403
+ severity: 'error',
404
+ retryable: false,
405
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
406
+ };
407
+ }
408
+ return null;
409
+ }
410
+ /** Read the account's other context rules and report where this install's
411
+ * signers already hold authority over the same calls.
412
+ *
413
+ * A failure to read is reported rather than thrown, and the caller-facing
414
+ * decision is made by `enforceAuthorityScan`: a scan that tried and failed
415
+ * tells us nothing, so it refuses rather than passing as a clean account.
416
+ *
417
+ * The account data this trusts comes from whichever RPC answered, so a
418
+ * hostile RPC could describe an account with no overlapping rules. That is
419
+ * bounded by the pin already enforced above: `enforceRpcPin` returns before
420
+ * this runs, so the URL is the pinned one for the network unless the caller
421
+ * explicitly set `allowUnpinnedRpcUrl`. This scan deliberately does not add a
422
+ * second pin check, because two places deciding the same thing drift. */
423
+ async function scanAuthorityOverlap(args) {
424
+ try {
425
+ if (!args.encodedPredicate) {
426
+ // Not applicable rather than failed: this rule installs no interpreter
427
+ // predicate, so there is nothing of ours for another rule to undercut.
428
+ // Returning undefined keeps it out of the refusal path, which is
429
+ // reserved for scans that were meant to run and could not.
430
+ return undefined;
431
+ }
432
+ const server = new stellar_sdk_1.rpc.Server(args.rpcUrl, { allowHttp: false });
433
+ const reader = (0, read_account_rules_ts_1.accountRuleReaderFromServer)(server, schemas_ts_1.NETWORK_PASSPHRASES[args.network]);
434
+ const collected = await (0, read_account_rules_ts_1.collectObservedRules)({
435
+ reader,
436
+ smartAccount: args.smartAccount,
437
+ interpreterAddress: args.interpreterAddress,
438
+ });
439
+ const overlaps = (0, authority_overlap_ts_1.findAuthorityOverlaps)({
440
+ intended: {
441
+ // `add_context_rule` always creates a NEW rule, so there is no id to
442
+ // exclude; -1 matches nothing on the account.
443
+ ruleId: -1,
444
+ contextType: toOverlapContextType(args.rule.contextRuleType),
445
+ signers: args.rule.signers,
446
+ predicate: (0, decode_ts_1.decodePredicate)(args.encodedPredicate),
447
+ },
448
+ existing: collected.rules,
449
+ });
450
+ return { ran: true, incomplete: collected.incomplete, overlaps };
451
+ }
452
+ catch (e) {
453
+ return {
454
+ ran: false,
455
+ reason: e instanceof Error ? e.message : String(e),
456
+ overlaps: [],
457
+ };
458
+ }
459
+ }
460
+ /** The rule draft names the callee `contract`; the analyser calls it
461
+ * `address`. Same value, two vocabularies. */
462
+ function toOverlapContextType(ct) {
463
+ switch (ct.kind) {
464
+ case 'call_contract':
465
+ return { kind: 'call_contract', address: ct.contract };
466
+ case 'create_contract':
467
+ return { kind: 'create_contract', wasmHash: ct.wasmHash };
468
+ default:
469
+ return { kind: 'default' };
470
+ }
471
+ }
472
+ /** `merge_policy` body - the tightening remedy for a cross-rule overlap.
473
+ *
474
+ * Replaces a rule's predicate with the conjunction of it and a new one. This
475
+ * is the action `install_policy` recommends when it reports an overlap
476
+ * between two rules our interpreter polices, and it is deliberately NOT
477
+ * something `install_policy` does on its own: it detaches a live policy, so
478
+ * the operator has to ask for it.
479
+ *
480
+ * Two transactions in order. `add_policy` refuses a policy already on the
481
+ * rule, so the old attachment goes first, and the second transaction cannot
482
+ * be simulated until the first confirms. */
483
+ async function runMergePolicy(raw) {
484
+ const parsed = schemas_ts_1.MergePolicyInputSchema.safeParse(raw);
485
+ if (!parsed.success) {
486
+ return { ok: false, error: validationError('install_policy', parsed.error.issues) };
487
+ }
488
+ const input = parsed.data;
489
+ const network = input.network ?? 'testnet';
490
+ const expectedInterpreter = schemas_ts_1.PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
491
+ const expectedRpc = schemas_ts_1.RPC_URL_BY_NETWORK[network];
492
+ const rpcPinningError = enforceRpcPin('install_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
493
+ if (rpcPinningError)
494
+ return { ok: false, error: rpcPinningError };
495
+ try {
496
+ const rpcUrl = input.rpcUrl ?? expectedRpc;
497
+ const server = new stellar_sdk_1.rpc.Server(rpcUrl, { allowHttp: false });
498
+ const reader = (0, read_account_rules_ts_1.accountRuleReaderFromServer)(server, schemas_ts_1.NETWORK_PASSPHRASES[network]);
499
+ const collected = await (0, read_account_rules_ts_1.collectObservedRules)({
500
+ reader,
501
+ smartAccount: input.smartAccount,
502
+ interpreterAddress: expectedInterpreter,
503
+ });
504
+ const rule = collected.rules.find((r) => r.id === input.ruleId);
505
+ if (!rule) {
506
+ return {
507
+ ok: false,
508
+ error: {
509
+ code: 'INSTALL_BUILD_FAILED',
510
+ message: `merge_policy: rule ${input.ruleId} was not found on ${input.smartAccount}${collected.incomplete
511
+ ? ' (the rule scan was incomplete, so it may exist but was not reached)'
512
+ : ''}`,
513
+ severity: 'error',
514
+ retryable: false,
515
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
516
+ },
517
+ };
518
+ }
519
+ const plan = (0, plan_merge_policy_ts_1.planMergePolicy)({
520
+ rule,
521
+ interpreterAddress: expectedInterpreter,
522
+ incoming: (0, decode_ts_1.decodePredicate)(input.incomingPredicateBlobBase64),
523
+ step: input.step,
524
+ });
525
+ if (!plan.ok) {
526
+ return {
527
+ ok: false,
528
+ error: {
529
+ code: 'INSTALL_BUILD_FAILED',
530
+ message: `merge_policy: ${plan.reason}`,
531
+ severity: 'error',
532
+ retryable: false,
533
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
534
+ },
535
+ };
536
+ }
537
+ const encoded = (0, encode_ts_1.encodePredicate)(plan.predicate);
538
+ // The nonce is read, not assumed. OZ's `remove_policy` discards the result
539
+ // of `try_uninstall`, so a detach whose uninstall panicked - our
540
+ // `uninstall` panics MissingState when the master set has been archived -
541
+ // detaches the policy while leaving our nonce behind. Re-installing at 1
542
+ // would then be refused as a replay and the rule would sit unpoliced.
543
+ let installNonce = 1;
544
+ const nonceWarnings = [];
545
+ if (input.step === 'reinstall') {
546
+ const entries = await server.getLedgerEntries((0, read_account_rules_ts_1.nonceLedgerKey)(expectedInterpreter, input.smartAccount, input.ruleId));
547
+ const raw = entries.entries?.[0]?.val;
548
+ const stored = raw && raw.switch() === stellar_sdk_1.xdr.LedgerEntryType.contractData()
549
+ ? raw.contractData().val()
550
+ : undefined;
551
+ if (stored && stored.switch() === stellar_sdk_1.xdr.ScValType.scvU32()) {
552
+ installNonce = stored.u32() + 1;
553
+ 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.`);
554
+ }
555
+ }
556
+ const built = await (0, build_merge_policy_ts_1.buildMergePolicyXdr)({
557
+ smartAccount: input.smartAccount,
558
+ sourceAccount: input.sourceAccount,
559
+ networkPassphrase: schemas_ts_1.NETWORK_PASSPHRASES[network],
560
+ ruleId: input.ruleId,
561
+ policyId: plan.policyId,
562
+ interpreterAddress: expectedInterpreter,
563
+ step: input.step,
564
+ encodedPredicate: encoded.encodedPredicate,
565
+ predicateHash: encoded.predicateHash,
566
+ installNonce,
567
+ ...(plan.oracleParams ? { oracleParams: plan.oracleParams } : {}),
568
+ rpc: (0, build_install_policy_ts_1.rpcClientFromServer)(server, schemas_ts_1.NETWORK_PASSPHRASES[network]),
569
+ ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
570
+ });
571
+ return {
572
+ ok: true,
573
+ data: {
574
+ ...built,
575
+ mergedPredicateHash: encoded.predicateHash,
576
+ mergedPredicateBlobBase64: encoded.encodedPredicate,
577
+ warnings: [...plan.warnings, ...nonceWarnings],
578
+ followUp: plan.followUp,
579
+ },
580
+ };
581
+ }
582
+ catch (e) {
583
+ return { ok: false, error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e) };
584
+ }
585
+ }
309
586
  /** `revoke_policy` body - thin wrapper over `buildRevokePolicyXdr`.
310
587
  * Emits an unsigned XDR for `account.remove_context_rule(ruleId)`; the
311
588
  * smart account itself handles uninstalling each attached policy. Auth
@@ -326,17 +603,9 @@ async function runRevokePolicy(raw) {
326
603
  const input = parsed.data;
327
604
  const network = input.network ?? 'testnet';
328
605
  const expectedRpc = schemas_ts_1.RPC_URL_BY_NETWORK[network];
329
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
330
- return {
331
- ok: false,
332
- error: {
333
- code: 'REVOKE_BUILD_FAILED',
334
- message: `revoke_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
335
- severity: 'error',
336
- retryable: false,
337
- remediation: { toolCall: { name: 'revoke_policy', args: {} } },
338
- },
339
- };
606
+ const rpcPinningError = enforceRpcPin('revoke_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
607
+ if (rpcPinningError) {
608
+ return { ok: false, error: rpcPinningError };
340
609
  }
341
610
  let rpcClient;
342
611
  try {
@@ -373,13 +642,18 @@ async function runRevokePolicy(raw) {
373
642
  * fabricating it would be a lie on a security surface; the live
374
643
  * mismatch check is worth MORE).
375
644
  *
376
- * Network-aware: `input.network` selects which interpreter pin and RPC
377
- * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
378
- * uploaded to mainnet as was exercised on testnet, so a single
379
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks
380
- * (DEPLOYMENTS.md:25-27). The address differs because instance ids are
381
- * network-scoped. UNAUDITED at the time of writing - see
382
- * DEPLOYMENTS.md:29-32 for what is still pending. */
645
+ * Network-aware: `input.network` selects the interpreter address, the RPC
646
+ * and the wasm hash. The networks run different binaries - testnet carries
647
+ * the selector-leaf minimum and the signer-set cap, mainnet predates both -
648
+ * so the hash is read through
649
+ * `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK`. UNAUDITED at the time of
650
+ * writing.
651
+ *
652
+ * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
653
+ * call, the auth-digest + the answer bind to whichever RPC answered, so
654
+ * a non-pinned `rpcUrl` would silently bind the caller to a host they
655
+ * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
656
+ * opt-in as install/revoke. */
383
657
  async function runGetInterpreterInfo(raw) {
384
658
  const parsed = schemas_ts_1.GetInterpreterInfoInputSchema.safeParse(raw);
385
659
  if (!parsed.success) {
@@ -390,6 +664,19 @@ async function runGetInterpreterInfo(raw) {
390
664
  }
391
665
  const input = parsed.data;
392
666
  const network = input.network ?? 'testnet';
667
+ const expectedRpc = schemas_ts_1.RPC_URL_BY_NETWORK[network];
668
+ // Pin enforcement only matters when the live RPC call is engaged. The
669
+ // static-fingerprint branch is local data and a non-pinned URL never
670
+ // reaches the network, so the gate is conditioned on `verifyLive` to
671
+ // match the wire-level concern (an outbound request that returns a
672
+ // caller-bound value). A caller that DOES NOT verifyLive can supply any
673
+ // URL it likes; the field is still optional.
674
+ if (input.verifyLive === true) {
675
+ const rpcPinningError = enforceRpcPin('get_interpreter_info', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
676
+ if (rpcPinningError) {
677
+ return { ok: false, error: rpcPinningError };
678
+ }
679
+ }
393
680
  const pinnedAddress = schemas_ts_1.PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
394
681
  try {
395
682
  let deployedGrammarVersion;
@@ -400,7 +687,7 @@ async function runGetInterpreterInfo(raw) {
400
687
  const info = (0, get_interpreter_info_ts_1.getInterpreterInfo)({
401
688
  pinnedAddress,
402
689
  pinnedGrammarVersion: schemas_ts_1.PINNED_INTERPRETER_GRAMMAR_VERSION,
403
- pinnedWasmHash: schemas_ts_1.PINNED_INTERPRETER_WASM_SHA256,
690
+ pinnedWasmHash: schemas_ts_1.PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK[network],
404
691
  network,
405
692
  ...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
406
693
  });
@@ -427,15 +714,14 @@ function buildRpcClientFromInput(urlOverride, network) {
427
714
  // needs the full Server surface.
428
715
  return (0, build_install_policy_ts_1.rpcClientFromServer)(new stellar_sdk_1.rpc.Server(url, { allowHttp: false }), passphrase);
429
716
  }
430
- /** Default-deny: refuse any interpreter policy whose address differs from
431
- * the pinned interpreter for the selected network. An interpreter the
432
- * caller controls can permit anything, so the smart account's
433
- * authorization must bind to the pinned contract unless the caller
434
- * explicitly opts in via `allowUnpinnedInterpreter`. OZ built-in
435
- * policies are not interpreters and pass through unchanged. Returns a
436
- * ToolError to surface through the run-layer envelope, or null when the
437
- * policies are all pinned. The caller resolves the expected pin per
438
- * network; this function stays pure so it is easy to test. */
717
+ /** Default-deny: refuse an interpreter policy whose address differs from the
718
+ * pinned interpreter for the selected network. An interpreter the caller
719
+ * controls can permit anything, so the smart account's authorization must
720
+ * bind to the pinned contract unless the caller opts in. OZ built-in
721
+ * policies are not interpreters and pass through. Returns a ToolError to
722
+ * surface through the run-layer envelope, or null when all interpreter
723
+ * policies are pinned. The caller resolves the expected pin per network;
724
+ * this function stays pure so it is easy to test. */
439
725
  function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddress) {
440
726
  for (const p of policies) {
441
727
  if (p.kind !== 'interpreter')
@@ -454,58 +740,58 @@ function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddre
454
740
  }
455
741
  return null;
456
742
  }
743
+ /** Default-deny: refuse an `rpcUrl` that is not the pinned RPC for the
744
+ * selected network. The auth nonce + rootInvocation in the install/revoke
745
+ * response come from whichever RPC answered, so a non-pinned RPC would
746
+ * silently bind the caller to a host they picked. The same applies to
747
+ * `get_interpreter_info` when `verifyLive` is true (the live grammar
748
+ * version is the caller-bound value). The caller may opt in via
749
+ * `allowUnpinnedRpcUrl: true`. Returns a ToolError or null when the URL is
750
+ * pinned (or absent, since the default is the pinned one). */
751
+ function enforceRpcPin(toolName, rpcUrl, allowUnpinned, expectedRpc, network) {
752
+ if (!rpcUrl || rpcUrl === expectedRpc || allowUnpinned === true)
753
+ return null;
754
+ const code = toolName === 'install_policy'
755
+ ? 'INSTALL_BUILD_FAILED'
756
+ : toolName === 'revoke_policy'
757
+ ? 'REVOKE_BUILD_FAILED'
758
+ : 'RECORDING_FAILED';
759
+ return {
760
+ code,
761
+ message: `${toolName}: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
762
+ severity: 'error',
763
+ retryable: false,
764
+ remediation: { toolCall: { name: toolName, args: {} } },
765
+ };
766
+ }
457
767
  /** Build a canonical ToolError for a Zod validation failure. The remediation
458
768
  * hint points the agent back at the right tool with an empty arg bag - the
459
769
  * tool name IS the machine-readable hint. */
460
770
  function validationError(toolName, issues) {
461
- const code = toolName === 'record_transaction'
462
- ? 'RECORDING_FAILED'
463
- : toolName === 'synthesize_policy'
464
- ? 'SYNTHESIS_ERROR'
465
- : toolName === 'simulate_policy'
466
- ? 'SIMULATION_ERROR'
467
- : toolName === 'verify_policy'
468
- ? 'VERIFICATION_FAILED'
469
- : toolName === 'install_policy'
470
- ? 'INSTALL_BUILD_FAILED'
471
- : toolName === 'revoke_policy'
472
- ? 'REVOKE_BUILD_FAILED'
473
- : 'RECORDING_FAILED';
474
771
  return {
475
- code,
772
+ code: TOOL_ERROR_CODE[toolName],
476
773
  message: `${toolName}: invalid input: ${issues
477
774
  .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
478
775
  .join('; ')}`,
479
776
  severity: 'error',
480
777
  retryable: false,
481
- remediation: {
482
- toolCall: { name: toolName, args: {} },
483
- },
778
+ remediation: { toolCall: { name: toolName, args: {} } },
484
779
  };
485
780
  }
486
781
  /** Build a canonical ToolError for a thrown exception caught by the tool
487
782
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
488
783
  * default, so we extract a string-friendly message and tag the original
489
- * error in `details` for the agent to inspect. The `code` is the tool's
490
- * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
491
- * for `synthesize_policy`) so the agent dispatches on the same code the
492
- * structured ToolError would carry.
493
- *
494
- * Exported as `_caughtError` (the leading underscore signals the test-only
495
- * seam) so the suite in run/index.test.ts can drive the envelope path
496
- * without standing up a full recordTransaction pipeline. */
784
+ * error in `details` for the agent to inspect. Exported as a test-only seam
785
+ * so the suite in run/index.test.ts can drive the envelope path without
786
+ * standing up a full recordTransaction pipeline. */
497
787
  function caughtError(toolName, code, e) {
498
- const message = describeThrown(e, toolName);
499
- const details = { thrown: safeStringify(e) };
500
788
  return {
501
789
  code,
502
- message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
790
+ message: `${toolName}: unhandled throw escaped core envelope: ${describeThrown(e, toolName)}`,
503
791
  severity: 'error',
504
792
  retryable: false,
505
- remediation: {
506
- toolCall: { name: toolName, args: {} },
507
- },
508
- details,
793
+ remediation: { toolCall: { name: toolName, args: {} } },
794
+ details: { thrown: safeStringify(e) },
509
795
  };
510
796
  }
511
797
  /** Build a human-readable message for an unknown caught value. Order matters: