@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/src/run/index.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  // drive the CLI (which calls into the same core directly without MCP).
19
19
 
20
20
  import { createHash } from 'node:crypto'
21
- import { rpc } from '@stellar/stellar-sdk'
21
+ import { rpc, xdr } from '@stellar/stellar-sdk'
22
22
  import {
23
23
  type ErrorCode,
24
24
  type MandateSpec,
@@ -35,6 +35,11 @@ import {
35
35
  type ToolError,
36
36
  type ToolResponse,
37
37
  } from '../index.ts'
38
+ import {
39
+ type AuthorityOverlap,
40
+ type ContextType,
41
+ findAuthorityOverlaps,
42
+ } from '../install/authority-overlap.ts'
38
43
  import {
39
44
  type BuildInstallPolicyResult,
40
45
  type BuildRevokePolicyResult,
@@ -43,7 +48,16 @@ import {
43
48
  type InstallRpcClient,
44
49
  rpcClientFromServer,
45
50
  } from '../install/build-install-policy.ts'
51
+ import { buildMergePolicyXdr } from '../install/build-merge-policy.ts'
46
52
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
53
+ import { planMergePolicy } from '../install/plan-merge-policy.ts'
54
+ import {
55
+ accountRuleReaderFromServer,
56
+ collectObservedRules,
57
+ nonceLedgerKey,
58
+ } from '../install/read-account-rules.ts'
59
+ import { decodePredicate } from '../predicate/decode.ts'
60
+ import { encodePredicate } from '../predicate/encode.ts'
47
61
  import type { SimulationResult } from '../verify/envelope.ts'
48
62
  import { simulatePolicy, verifyPolicy } from '../verify/index.ts'
49
63
  import {
@@ -51,10 +65,12 @@ import {
51
65
  GetInterpreterInfoInputSchema,
52
66
  type InstallPolicyInput,
53
67
  InstallPolicyInputSchema,
68
+ type MergePolicyInput,
69
+ MergePolicyInputSchema,
54
70
  NETWORK_PASSPHRASES,
55
71
  PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
56
72
  PINNED_INTERPRETER_GRAMMAR_VERSION,
57
- PINNED_INTERPRETER_WASM_SHA256,
73
+ PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK,
58
74
  type RecordTransactionInput,
59
75
  RecordTransactionInputSchema,
60
76
  type RevokePolicyInput,
@@ -95,7 +111,7 @@ export {
95
111
  PINNED_INTERPRETER_GRAMMAR_VERSION,
96
112
  PINNED_INTERPRETER_MAINNET_ADDRESS,
97
113
  PINNED_INTERPRETER_TESTNET_ADDRESS,
98
- PINNED_INTERPRETER_WASM_SHA256,
114
+ PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK,
99
115
  PredicateLeafSchema,
100
116
  PredicateNodeSchema,
101
117
  RecordedTransactionSchema,
@@ -110,13 +126,32 @@ export {
110
126
  } from './schemas.ts'
111
127
 
112
128
  export type RunRecordTransactionInput = RecordTransactionInput
113
-
114
129
  export type RunSynthesizePolicyInput = SynthesizePolicyInput
115
-
116
130
  export type RunSimulatePolicyInput = SimulatePolicyInput
117
-
118
131
  export type RunVerifyPolicyInput = VerifyPolicyInput
119
132
 
133
+ type RunToolName =
134
+ | 'record_transaction'
135
+ | 'synthesize_policy'
136
+ | 'simulate_policy'
137
+ | 'verify_policy'
138
+ | 'install_policy'
139
+ | 'revoke_policy'
140
+ | 'get_interpreter_info'
141
+
142
+ /** Map every tool name to its canonical domain error code. Replaces a 7-way
143
+ * if/else so adding a tool adds one line here rather than a new branch in
144
+ * each envelope call. */
145
+ const TOOL_ERROR_CODE: Record<RunToolName, ErrorCode> = {
146
+ record_transaction: 'RECORDING_FAILED',
147
+ synthesize_policy: 'SYNTHESIS_ERROR',
148
+ simulate_policy: 'SIMULATION_ERROR',
149
+ verify_policy: 'VERIFICATION_FAILED',
150
+ install_policy: 'INSTALL_BUILD_FAILED',
151
+ revoke_policy: 'REVOKE_BUILD_FAILED',
152
+ get_interpreter_info: 'RECORDING_FAILED',
153
+ }
154
+
120
155
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
121
156
  * matches the core RecordInput minus the injected `fetcher` (the transport
122
157
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -316,9 +351,7 @@ export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>>
316
351
  * comes from the RPC). Both gates accept an explicit opt-in flag.
317
352
  * Pin selection follows `input.network` (defaults to `testnet` so the
318
353
  * pre-mainnet callers keep working unchanged). */
319
- export async function runInstallPolicy(
320
- raw: unknown
321
- ): Promise<ToolResponse<BuildInstallPolicyResult>> {
354
+ export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<InstallPolicyResult>> {
322
355
  const parsed = InstallPolicyInputSchema.safeParse(raw)
323
356
  if (!parsed.success) {
324
357
  return {
@@ -339,17 +372,15 @@ export async function runInstallPolicy(
339
372
  if (pinningError) {
340
373
  return { ok: false, error: pinningError }
341
374
  }
342
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
343
- return {
344
- ok: false,
345
- error: {
346
- code: 'INSTALL_BUILD_FAILED',
347
- message: `install_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
348
- severity: 'error',
349
- retryable: false,
350
- remediation: { toolCall: { name: 'install_policy', args: {} } },
351
- },
352
- }
375
+ const rpcPinningError = enforceRpcPin(
376
+ 'install_policy',
377
+ input.rpcUrl,
378
+ input.allowUnpinnedRpcUrl,
379
+ expectedRpc,
380
+ network
381
+ )
382
+ if (rpcPinningError) {
383
+ return { ok: false, error: rpcPinningError }
353
384
  }
354
385
  let rpcClient: InstallRpcClient
355
386
  try {
@@ -366,6 +397,27 @@ export async function runInstallPolicy(
366
397
  const predicateHash = createHash('sha256')
367
398
  .update(Buffer.from(encodedPredicate, 'base64'))
368
399
  .digest('hex')
400
+
401
+ // ---- Cross-rule authority scan ----
402
+ // OZ enforces only the policies of the rule the caller names, so a signer
403
+ // who also sits in a wider rule keeps that wider authority no matter what
404
+ // this predicate says. Refuse by default when the wider rule has no policy
405
+ // at all, because that makes this install decorative.
406
+ const authorityScan: AuthorityScanReport | undefined = input.skipAuthorityScan
407
+ ? { ran: false, skipped: true, reason: 'skipped at caller request', overlaps: [] }
408
+ : await scanAuthorityOverlap({
409
+ smartAccount: input.smartAccount,
410
+ interpreterAddress: expectedInterpreter,
411
+ rule: input.rule,
412
+ encodedPredicate,
413
+ rpcUrl: input.rpcUrl ?? expectedRpc,
414
+ network,
415
+ })
416
+ const overlapError = enforceAuthorityScan(authorityScan, input.allowAuthorityOverlap)
417
+ if (overlapError) {
418
+ return { ok: false, error: overlapError }
419
+ }
420
+
369
421
  const result = await buildInstallPolicyXdr({
370
422
  smartAccount: input.smartAccount,
371
423
  sourceAccount: input.sourceAccount,
@@ -377,7 +429,7 @@ export async function runInstallPolicy(
377
429
  rpc: rpcClient,
378
430
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
379
431
  })
380
- return { ok: true, data: result }
432
+ return { ok: true, data: authorityScan ? { ...result, authorityScan } : result }
381
433
  } catch (e) {
382
434
  return {
383
435
  ok: false,
@@ -386,6 +438,343 @@ export async function runInstallPolicy(
386
438
  }
387
439
  }
388
440
 
441
+ /** Default-deny on the cross-rule scan.
442
+ *
443
+ * Refuses whenever the scan cannot establish that this policy binds the calls
444
+ * it names. An unpoliced neighbour provably does not constrain them. An
445
+ * opaque one, policed by a contract this tool cannot decode, is not KNOWN to,
446
+ * and "not known to" is not "safe" - the same posture as the interpreter and
447
+ * RPC pins. An incomplete scan is refused for the same reason: the overlap
448
+ * list is then a subset of the account, so an empty list proves nothing.
449
+ *
450
+ * `not-restricting` is reported but does NOT block. Both rules are ours and
451
+ * both constrain the calls, and the conjunction remedy is offered; refusing
452
+ * there would also block the legitimate act of adding a separate capability,
453
+ * which OZ composes correctly as a union.
454
+ *
455
+ * Returns a ToolError or null, matching `enforceInterpreterPin`. */
456
+ export function enforceAuthorityScan(
457
+ scan: AuthorityScanReport | undefined,
458
+ allowOverlap: boolean | undefined
459
+ ): ToolError | null {
460
+ if (!scan || allowOverlap === true) return null
461
+
462
+ // Default-deny by exclusion rather than by enumeration: anything that is not
463
+ // the one known-safe severity blocks. Listing the blocking severities
464
+ // instead would mean a severity added later silently passes until someone
465
+ // remembers to add it here, and the safe direction is the opposite.
466
+ const blocking = scan.overlaps.filter((o) => o.severity !== 'not-restricting')
467
+ const unpoliced = blocking.filter((o) => o.severity === 'bypass').map((o) => o.ruleId)
468
+ const opaque = blocking.filter((o) => o.severity === 'unknown').map((o) => o.ruleId)
469
+ const unrecognised = blocking
470
+ .filter((o) => o.severity !== 'bypass' && o.severity !== 'unknown')
471
+ .map((o) => o.ruleId)
472
+
473
+ if (blocking.length > 0) {
474
+ const parts = [
475
+ unpoliced.length > 0 ? `rule ${unpoliced.join(', ')} has no policy attached` : '',
476
+ opaque.length > 0
477
+ ? `rule ${opaque.join(', ')} is policed by a contract this tool cannot decode`
478
+ : '',
479
+ unrecognised.length > 0
480
+ ? `rule ${unrecognised.join(', ')} carries an overlap this build does not recognise`
481
+ : '',
482
+ ].filter(Boolean)
483
+ return {
484
+ code: 'INSTALL_BUILD_FAILED',
485
+ message: `install_policy: a signer of this rule can already make the same calls through another context rule, so this policy is not established to restrict them: ${parts.join('; ')}. Remove the shared signer from that rule, attach a policy this tool can read, or set allowAuthorityOverlap: true to install anyway`,
486
+ severity: 'error',
487
+ retryable: false,
488
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
489
+ }
490
+ }
491
+
492
+ // A scan that was meant to run and threw tells us nothing. Reporting it in
493
+ // a string field and proceeding is the same fail-open shape as an incomplete
494
+ // scan, and a caller that does not read `ran` cannot tell it from a clean
495
+ // result.
496
+ if (scan.ran === false && scan.skipped !== true) {
497
+ return {
498
+ code: 'INSTALL_BUILD_FAILED',
499
+ message: `install_policy: the cross-rule authority scan could not run (${scan.reason ?? 'unknown error'}), so it cannot establish that this policy restricts anything; retry, or set allowAuthorityOverlap: true to install without that assurance`,
500
+ severity: 'error',
501
+ retryable: true,
502
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
503
+ }
504
+ }
505
+
506
+ if (scan.incomplete === true) {
507
+ return {
508
+ code: 'INSTALL_BUILD_FAILED',
509
+ message:
510
+ 'install_policy: the account has more context rules than the scan could account for, so the overlap result is incomplete and cannot establish that this policy restricts anything; set allowAuthorityOverlap: true to install without that assurance',
511
+ severity: 'error',
512
+ retryable: false,
513
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
514
+ }
515
+ }
516
+
517
+ return null
518
+ }
519
+
520
+ /** The install response, plus what the cross-rule scan found. The scan is
521
+ * advisory data about the account, not part of the transaction, so it is
522
+ * additive: a caller that ignores it gets exactly the previous shape. */
523
+ export type InstallPolicyResult = BuildInstallPolicyResult & {
524
+ authorityScan?: AuthorityScanReport
525
+ }
526
+
527
+ /** What the cross-rule scan found, carried on the install response so the
528
+ * review surface can show it alongside the transaction being signed.
529
+ *
530
+ * SCOPE, and it is narrow: this answers "can a signer OF THIS RULE reach the
531
+ * same calls through a different rule". A rule sharing no signer with this
532
+ * one cannot be reached by this rule's signers, so it is not a way around
533
+ * this policy; it is a different principal's authority, which no policy
534
+ * installed here was ever going to constrain. Other rules keep their own
535
+ * signers, and an account administrator can add signers or rules afterwards.
536
+ *
537
+ * An empty `overlaps` is therefore NOT a statement that the account is safe,
538
+ * only that this rule's own signers gain no unconstrained path through the
539
+ * rules that exist right now. */
540
+ export interface AuthorityScanReport {
541
+ /** False when the scan did not run. `reason` then says why, and the absence
542
+ * of overlaps proves nothing. */
543
+ ran: boolean
544
+ /** True when the caller passed `skipAuthorityScan`. Distinguishes a
545
+ * deliberate skip from a scan that tried and failed: both carry
546
+ * `ran: false`, but only the failure refuses the install. Recorded rather
547
+ * than omitted so the response shows that no opinion was formed, instead
548
+ * of looking like a version that never had the check. */
549
+ skipped?: boolean
550
+ /** True when the account has more rules than the scan accounted for, so the
551
+ * overlap list is a subset. */
552
+ incomplete?: boolean
553
+ reason?: string
554
+ overlaps: AuthorityOverlap[]
555
+ }
556
+
557
+ /** Read the account's other context rules and report where this install's
558
+ * signers already hold authority over the same calls.
559
+ *
560
+ * A failure to read is reported rather than thrown, and the caller-facing
561
+ * decision is made by `enforceAuthorityScan`: a scan that tried and failed
562
+ * tells us nothing, so it refuses rather than passing as a clean account.
563
+ *
564
+ * The account data this trusts comes from whichever RPC answered, so a
565
+ * hostile RPC could describe an account with no overlapping rules. That is
566
+ * bounded by the pin already enforced above: `enforceRpcPin` returns before
567
+ * this runs, so the URL is the pinned one for the network unless the caller
568
+ * explicitly set `allowUnpinnedRpcUrl`. This scan deliberately does not add a
569
+ * second pin check, because two places deciding the same thing drift. */
570
+ async function scanAuthorityOverlap(args: {
571
+ smartAccount: string
572
+ interpreterAddress: string
573
+ rule: InstallPolicyInput['rule']
574
+ encodedPredicate: string
575
+ rpcUrl: string
576
+ network: Network
577
+ }): Promise<AuthorityScanReport | undefined> {
578
+ try {
579
+ if (!args.encodedPredicate) {
580
+ // Not applicable rather than failed: this rule installs no interpreter
581
+ // predicate, so there is nothing of ours for another rule to undercut.
582
+ // Returning undefined keeps it out of the refusal path, which is
583
+ // reserved for scans that were meant to run and could not.
584
+ return undefined
585
+ }
586
+ const server = new rpc.Server(args.rpcUrl, { allowHttp: false })
587
+ const reader = accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[args.network])
588
+ const collected = await collectObservedRules({
589
+ reader,
590
+ smartAccount: args.smartAccount,
591
+ interpreterAddress: args.interpreterAddress,
592
+ })
593
+ const overlaps = findAuthorityOverlaps({
594
+ intended: {
595
+ // `add_context_rule` always creates a NEW rule, so there is no id to
596
+ // exclude; -1 matches nothing on the account.
597
+ ruleId: -1,
598
+ contextType: toOverlapContextType(args.rule.contextRuleType),
599
+ signers: args.rule.signers,
600
+ predicate: decodePredicate(args.encodedPredicate),
601
+ },
602
+ existing: collected.rules,
603
+ })
604
+ return { ran: true, incomplete: collected.incomplete, overlaps }
605
+ } catch (e) {
606
+ return {
607
+ ran: false,
608
+ reason: e instanceof Error ? e.message : String(e),
609
+ overlaps: [],
610
+ }
611
+ }
612
+ }
613
+
614
+ /** The rule draft names the callee `contract`; the analyser calls it
615
+ * `address`. Same value, two vocabularies. */
616
+ function toOverlapContextType(ct: InstallPolicyInput['rule']['contextRuleType']): ContextType {
617
+ switch (ct.kind) {
618
+ case 'call_contract':
619
+ return { kind: 'call_contract', address: ct.contract }
620
+ case 'create_contract':
621
+ return { kind: 'create_contract', wasmHash: ct.wasmHash }
622
+ default:
623
+ return { kind: 'default' }
624
+ }
625
+ }
626
+
627
+ /** The merge response: one step's transaction plus what it will cost. */
628
+ export interface MergePolicyResult {
629
+ unsignedXdr: string
630
+ smartAccount: string
631
+ sourceAccount: string
632
+ step: 'detach' | 'reinstall'
633
+ call: { contract: string; fn: string; ruleId: number }
634
+ authNonce: string
635
+ authValidUntilLedger: number
636
+ rootInvocationXdr: string
637
+ /** sha256 of the merged predicate, so the caller can pin what step 2 will
638
+ * install while they are still looking at step 1. */
639
+ mergedPredicateHash: string
640
+ mergedPredicateBlobBase64: string
641
+ warnings: string[]
642
+ followUp: string
643
+ }
644
+
645
+ /** `merge_policy` body - the tightening remedy for a cross-rule overlap.
646
+ *
647
+ * Replaces a rule's predicate with the conjunction of it and a new one. This
648
+ * is the action `install_policy` recommends when it reports an overlap
649
+ * between two rules our interpreter polices, and it is deliberately NOT
650
+ * something `install_policy` does on its own: it detaches a live policy, so
651
+ * the operator has to ask for it.
652
+ *
653
+ * Two transactions in order. `add_policy` refuses a policy already on the
654
+ * rule, so the old attachment goes first, and the second transaction cannot
655
+ * be simulated until the first confirms. */
656
+ export async function runMergePolicy(raw: unknown): Promise<ToolResponse<MergePolicyResult>> {
657
+ const parsed = MergePolicyInputSchema.safeParse(raw)
658
+ if (!parsed.success) {
659
+ return { ok: false, error: validationError('install_policy', parsed.error.issues) }
660
+ }
661
+ const input: MergePolicyInput = parsed.data
662
+ const network: Network = input.network ?? 'testnet'
663
+ const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
664
+ const expectedRpc = RPC_URL_BY_NETWORK[network]
665
+
666
+ const rpcPinningError = enforceRpcPin(
667
+ 'install_policy',
668
+ input.rpcUrl,
669
+ input.allowUnpinnedRpcUrl,
670
+ expectedRpc,
671
+ network
672
+ )
673
+ if (rpcPinningError) return { ok: false, error: rpcPinningError }
674
+
675
+ try {
676
+ const rpcUrl = input.rpcUrl ?? expectedRpc
677
+ const server = new rpc.Server(rpcUrl, { allowHttp: false })
678
+ const reader = accountRuleReaderFromServer(server, NETWORK_PASSPHRASES[network])
679
+ const collected = await collectObservedRules({
680
+ reader,
681
+ smartAccount: input.smartAccount,
682
+ interpreterAddress: expectedInterpreter,
683
+ })
684
+ const rule = collected.rules.find((r) => r.id === input.ruleId)
685
+ if (!rule) {
686
+ return {
687
+ ok: false,
688
+ error: {
689
+ code: 'INSTALL_BUILD_FAILED',
690
+ message: `merge_policy: rule ${input.ruleId} was not found on ${input.smartAccount}${
691
+ collected.incomplete
692
+ ? ' (the rule scan was incomplete, so it may exist but was not reached)'
693
+ : ''
694
+ }`,
695
+ severity: 'error',
696
+ retryable: false,
697
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
698
+ },
699
+ }
700
+ }
701
+
702
+ const plan = planMergePolicy({
703
+ rule,
704
+ interpreterAddress: expectedInterpreter,
705
+ incoming: decodePredicate(input.incomingPredicateBlobBase64),
706
+ step: input.step,
707
+ })
708
+ if (!plan.ok) {
709
+ return {
710
+ ok: false,
711
+ error: {
712
+ code: 'INSTALL_BUILD_FAILED',
713
+ message: `merge_policy: ${plan.reason}`,
714
+ severity: 'error',
715
+ retryable: false,
716
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
717
+ },
718
+ }
719
+ }
720
+
721
+ const encoded = encodePredicate(plan.predicate)
722
+
723
+ // The nonce is read, not assumed. OZ's `remove_policy` discards the result
724
+ // of `try_uninstall`, so a detach whose uninstall panicked - our
725
+ // `uninstall` panics MissingState when the master set has been archived -
726
+ // detaches the policy while leaving our nonce behind. Re-installing at 1
727
+ // would then be refused as a replay and the rule would sit unpoliced.
728
+ let installNonce = 1
729
+ const nonceWarnings: string[] = []
730
+ if (input.step === 'reinstall') {
731
+ const entries = await server.getLedgerEntries(
732
+ nonceLedgerKey(expectedInterpreter, input.smartAccount, input.ruleId)
733
+ )
734
+ const raw = entries.entries?.[0]?.val
735
+ const stored =
736
+ raw && raw.switch() === xdr.LedgerEntryType.contractData()
737
+ ? raw.contractData().val()
738
+ : undefined
739
+ if (stored && stored.switch() === xdr.ScValType.scvU32()) {
740
+ installNonce = stored.u32() + 1
741
+ nonceWarnings.push(
742
+ `the previous uninstall did not complete: rule ${input.ruleId} still holds interpreter state at nonce ${stored.u32()}, so this reinstalls at ${installNonce} rather than 1. The rule's counters were NOT reset.`
743
+ )
744
+ }
745
+ }
746
+
747
+ const built = await buildMergePolicyXdr({
748
+ smartAccount: input.smartAccount,
749
+ sourceAccount: input.sourceAccount,
750
+ networkPassphrase: NETWORK_PASSPHRASES[network],
751
+ ruleId: input.ruleId,
752
+ policyId: plan.policyId,
753
+ interpreterAddress: expectedInterpreter,
754
+ step: input.step,
755
+ encodedPredicate: encoded.encodedPredicate,
756
+ predicateHash: encoded.predicateHash,
757
+ installNonce,
758
+ ...(plan.oracleParams ? { oracleParams: plan.oracleParams } : {}),
759
+ rpc: rpcClientFromServer(server, NETWORK_PASSPHRASES[network]),
760
+ ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
761
+ })
762
+
763
+ return {
764
+ ok: true,
765
+ data: {
766
+ ...built,
767
+ mergedPredicateHash: encoded.predicateHash,
768
+ mergedPredicateBlobBase64: encoded.encodedPredicate,
769
+ warnings: [...plan.warnings, ...nonceWarnings],
770
+ followUp: plan.followUp,
771
+ },
772
+ }
773
+ } catch (e) {
774
+ return { ok: false, error: caughtError('install_policy', 'INSTALL_BUILD_FAILED', e) }
775
+ }
776
+ }
777
+
389
778
  /** `revoke_policy` body - thin wrapper over `buildRevokePolicyXdr`.
390
779
  * Emits an unsigned XDR for `account.remove_context_rule(ruleId)`; the
391
780
  * smart account itself handles uninstalling each attached policy. Auth
@@ -408,17 +797,15 @@ export async function runRevokePolicy(
408
797
  const input: RevokePolicyInput = parsed.data
409
798
  const network: Network = input.network ?? 'testnet'
410
799
  const expectedRpc = RPC_URL_BY_NETWORK[network]
411
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
412
- return {
413
- ok: false,
414
- error: {
415
- code: 'REVOKE_BUILD_FAILED',
416
- message: `revoke_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
417
- severity: 'error',
418
- retryable: false,
419
- remediation: { toolCall: { name: 'revoke_policy', args: {} } },
420
- },
421
- }
800
+ const rpcPinningError = enforceRpcPin(
801
+ 'revoke_policy',
802
+ input.rpcUrl,
803
+ input.allowUnpinnedRpcUrl,
804
+ expectedRpc,
805
+ network
806
+ )
807
+ if (rpcPinningError) {
808
+ return { ok: false, error: rpcPinningError }
422
809
  }
423
810
  let rpcClient: InstallRpcClient
424
811
  try {
@@ -454,13 +841,18 @@ export async function runRevokePolicy(
454
841
  * fabricating it would be a lie on a security surface; the live
455
842
  * mismatch check is worth MORE).
456
843
  *
457
- * Network-aware: `input.network` selects which interpreter pin and RPC
458
- * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
459
- * uploaded to mainnet as was exercised on testnet, so a single
460
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks
461
- * (DEPLOYMENTS.md:25-27). The address differs because instance ids are
462
- * network-scoped. UNAUDITED at the time of writing - see
463
- * DEPLOYMENTS.md:29-32 for what is still pending. */
844
+ * Network-aware: `input.network` selects the interpreter address, the RPC
845
+ * and the wasm hash. The networks run different binaries - testnet carries
846
+ * the selector-leaf minimum and the signer-set cap, mainnet predates both -
847
+ * so the hash is read through
848
+ * `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK`. UNAUDITED at the time of
849
+ * writing.
850
+ *
851
+ * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
852
+ * call, the auth-digest + the answer bind to whichever RPC answered, so
853
+ * a non-pinned `rpcUrl` would silently bind the caller to a host they
854
+ * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
855
+ * opt-in as install/revoke. */
464
856
  export async function runGetInterpreterInfo(
465
857
  raw: unknown
466
858
  ): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>> {
@@ -473,6 +865,25 @@ export async function runGetInterpreterInfo(
473
865
  }
474
866
  const input: GetInterpreterInfoInput = parsed.data
475
867
  const network: Network = input.network ?? 'testnet'
868
+ const expectedRpc = RPC_URL_BY_NETWORK[network]
869
+ // Pin enforcement only matters when the live RPC call is engaged. The
870
+ // static-fingerprint branch is local data and a non-pinned URL never
871
+ // reaches the network, so the gate is conditioned on `verifyLive` to
872
+ // match the wire-level concern (an outbound request that returns a
873
+ // caller-bound value). A caller that DOES NOT verifyLive can supply any
874
+ // URL it likes; the field is still optional.
875
+ if (input.verifyLive === true) {
876
+ const rpcPinningError = enforceRpcPin(
877
+ 'get_interpreter_info',
878
+ input.rpcUrl,
879
+ input.allowUnpinnedRpcUrl,
880
+ expectedRpc,
881
+ network
882
+ )
883
+ if (rpcPinningError) {
884
+ return { ok: false, error: rpcPinningError }
885
+ }
886
+ }
476
887
  const pinnedAddress = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
477
888
  try {
478
889
  let deployedGrammarVersion: number | undefined
@@ -483,7 +894,7 @@ export async function runGetInterpreterInfo(
483
894
  const info = getInterpreterInfo({
484
895
  pinnedAddress,
485
896
  pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
486
- pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256,
897
+ pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK[network],
487
898
  network,
488
899
  ...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
489
900
  })
@@ -514,15 +925,14 @@ function buildRpcClientFromInput(
514
925
  return rpcClientFromServer(new rpc.Server(url, { allowHttp: false }), passphrase)
515
926
  }
516
927
 
517
- /** Default-deny: refuse any interpreter policy whose address differs from
518
- * the pinned interpreter for the selected network. An interpreter the
519
- * caller controls can permit anything, so the smart account's
520
- * authorization must bind to the pinned contract unless the caller
521
- * explicitly opts in via `allowUnpinnedInterpreter`. OZ built-in
522
- * policies are not interpreters and pass through unchanged. Returns a
523
- * ToolError to surface through the run-layer envelope, or null when the
524
- * policies are all pinned. The caller resolves the expected pin per
525
- * network; this function stays pure so it is easy to test. */
928
+ /** Default-deny: refuse an interpreter policy whose address differs from the
929
+ * pinned interpreter for the selected network. An interpreter the caller
930
+ * controls can permit anything, so the smart account's authorization must
931
+ * bind to the pinned contract unless the caller opts in. OZ built-in
932
+ * policies are not interpreters and pass through. Returns a ToolError to
933
+ * surface through the run-layer envelope, or null when all interpreter
934
+ * policies are pinned. The caller resolves the expected pin per network;
935
+ * this function stays pure so it is easy to test. */
526
936
  function enforceInterpreterPin(
527
937
  policies: InstallPolicyInput['rule']['policies'],
528
938
  allowUnpinned: boolean | undefined,
@@ -543,81 +953,69 @@ function enforceInterpreterPin(
543
953
  return null
544
954
  }
545
955
 
956
+ /** Default-deny: refuse an `rpcUrl` that is not the pinned RPC for the
957
+ * selected network. The auth nonce + rootInvocation in the install/revoke
958
+ * response come from whichever RPC answered, so a non-pinned RPC would
959
+ * silently bind the caller to a host they picked. The same applies to
960
+ * `get_interpreter_info` when `verifyLive` is true (the live grammar
961
+ * version is the caller-bound value). The caller may opt in via
962
+ * `allowUnpinnedRpcUrl: true`. Returns a ToolError or null when the URL is
963
+ * pinned (or absent, since the default is the pinned one). */
964
+ function enforceRpcPin(
965
+ toolName: 'install_policy' | 'revoke_policy' | 'get_interpreter_info',
966
+ rpcUrl: string | undefined,
967
+ allowUnpinned: boolean | undefined,
968
+ expectedRpc: string,
969
+ network: Network
970
+ ): ToolError | null {
971
+ if (!rpcUrl || rpcUrl === expectedRpc || allowUnpinned === true) return null
972
+ const code: ErrorCode =
973
+ toolName === 'install_policy'
974
+ ? 'INSTALL_BUILD_FAILED'
975
+ : toolName === 'revoke_policy'
976
+ ? 'REVOKE_BUILD_FAILED'
977
+ : 'RECORDING_FAILED'
978
+ return {
979
+ code,
980
+ message: `${toolName}: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
981
+ severity: 'error',
982
+ retryable: false,
983
+ remediation: { toolCall: { name: toolName, args: {} } },
984
+ }
985
+ }
986
+
546
987
  /** Build a canonical ToolError for a Zod validation failure. The remediation
547
988
  * hint points the agent back at the right tool with an empty arg bag - the
548
989
  * tool name IS the machine-readable hint. */
549
990
  function validationError(
550
- toolName:
551
- | 'record_transaction'
552
- | 'synthesize_policy'
553
- | 'simulate_policy'
554
- | 'verify_policy'
555
- | 'install_policy'
556
- | 'revoke_policy'
557
- | 'get_interpreter_info',
991
+ toolName: RunToolName,
558
992
  issues: ReadonlyArray<{ path: ReadonlyArray<string | number>; message: string }>
559
993
  ): ToolError {
560
- const code: ErrorCode =
561
- toolName === 'record_transaction'
562
- ? 'RECORDING_FAILED'
563
- : toolName === 'synthesize_policy'
564
- ? 'SYNTHESIS_ERROR'
565
- : toolName === 'simulate_policy'
566
- ? 'SIMULATION_ERROR'
567
- : toolName === 'verify_policy'
568
- ? 'VERIFICATION_FAILED'
569
- : toolName === 'install_policy'
570
- ? 'INSTALL_BUILD_FAILED'
571
- : toolName === 'revoke_policy'
572
- ? 'REVOKE_BUILD_FAILED'
573
- : 'RECORDING_FAILED'
574
994
  return {
575
- code,
995
+ code: TOOL_ERROR_CODE[toolName],
576
996
  message: `${toolName}: invalid input: ${issues
577
997
  .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
578
998
  .join('; ')}`,
579
999
  severity: 'error',
580
1000
  retryable: false,
581
- remediation: {
582
- toolCall: { name: toolName, args: {} },
583
- },
1001
+ remediation: { toolCall: { name: toolName, args: {} } },
584
1002
  }
585
1003
  }
586
1004
 
587
1005
  /** Build a canonical ToolError for a thrown exception caught by the tool
588
1006
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
589
1007
  * default, so we extract a string-friendly message and tag the original
590
- * error in `details` for the agent to inspect. The `code` is the tool's
591
- * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
592
- * for `synthesize_policy`) so the agent dispatches on the same code the
593
- * structured ToolError would carry.
594
- *
595
- * Exported as `_caughtError` (the leading underscore signals the test-only
596
- * seam) so the suite in run/index.test.ts can drive the envelope path
597
- * without standing up a full recordTransaction pipeline. */
598
- export function caughtError(
599
- toolName:
600
- | 'record_transaction'
601
- | 'synthesize_policy'
602
- | 'simulate_policy'
603
- | 'verify_policy'
604
- | 'install_policy'
605
- | 'revoke_policy'
606
- | 'get_interpreter_info',
607
- code: ErrorCode,
608
- e: unknown
609
- ): ToolError {
610
- const message = describeThrown(e, toolName)
611
- const details = { thrown: safeStringify(e) }
1008
+ * error in `details` for the agent to inspect. Exported as a test-only seam
1009
+ * so the suite in run/index.test.ts can drive the envelope path without
1010
+ * standing up a full recordTransaction pipeline. */
1011
+ export function caughtError(toolName: RunToolName, code: ErrorCode, e: unknown): ToolError {
612
1012
  return {
613
1013
  code,
614
- message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
1014
+ message: `${toolName}: unhandled throw escaped core envelope: ${describeThrown(e, toolName)}`,
615
1015
  severity: 'error',
616
1016
  retryable: false,
617
- remediation: {
618
- toolCall: { name: toolName, args: {} },
619
- },
620
- details,
1017
+ remediation: { toolCall: { name: toolName, args: {} } },
1018
+ details: { thrown: safeStringify(e) },
621
1019
  }
622
1020
  }
623
1021