@crediolabs/policy-synth 0.1.18 → 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 (42) hide show
  1. package/README.md +3 -2
  2. package/dist/install/authority-overlap.d.ts +134 -0
  3. package/dist/install/authority-overlap.js +0 -0
  4. package/dist/install/build-add-context-rule.d.ts +8 -0
  5. package/dist/install/build-add-context-rule.js +1 -1
  6. package/dist/install/build-merge-policy.d.ts +70 -0
  7. package/dist/install/build-merge-policy.js +130 -0
  8. package/dist/install/index.d.ts +2 -0
  9. package/dist/install/index.js +7 -0
  10. package/dist/install/plan-merge-policy.d.ts +49 -0
  11. package/dist/install/plan-merge-policy.js +86 -0
  12. package/dist/install/read-account-rules.d.ts +100 -0
  13. package/dist/install/read-account-rules.js +283 -0
  14. package/dist/run/index.d.ts +93 -8
  15. package/dist/run/index.js +282 -11
  16. package/dist/run/schemas.d.ts +290 -11
  17. package/dist/run/schemas.js +77 -11
  18. package/dist-cjs/install/authority-overlap.d.ts +134 -0
  19. package/dist-cjs/install/authority-overlap.js +0 -0
  20. package/dist-cjs/install/build-add-context-rule.d.ts +8 -0
  21. package/dist-cjs/install/build-add-context-rule.js +1 -0
  22. package/dist-cjs/install/build-merge-policy.d.ts +70 -0
  23. package/dist-cjs/install/build-merge-policy.js +134 -0
  24. package/dist-cjs/install/index.d.ts +2 -0
  25. package/dist-cjs/install/index.js +23 -2
  26. package/dist-cjs/install/plan-merge-policy.d.ts +49 -0
  27. package/dist-cjs/install/plan-merge-policy.js +90 -0
  28. package/dist-cjs/install/read-account-rules.d.ts +100 -0
  29. package/dist-cjs/install/read-account-rules.js +296 -0
  30. package/dist-cjs/run/index.d.ts +93 -8
  31. package/dist-cjs/run/index.js +283 -10
  32. package/dist-cjs/run/schemas.d.ts +290 -11
  33. package/dist-cjs/run/schemas.js +78 -12
  34. package/package.json +1 -1
  35. package/src/install/authority-overlap.ts +0 -0
  36. package/src/install/build-add-context-rule.ts +12 -1
  37. package/src/install/build-merge-policy.ts +219 -0
  38. package/src/install/index.ts +34 -0
  39. package/src/install/plan-merge-policy.ts +133 -0
  40. package/src/install/read-account-rules.ts +376 -0
  41. package/src/run/index.ts +386 -14
  42. package/src/run/schemas.ts +84 -11
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,
@@ -335,9 +351,7 @@ export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>>
335
351
  * comes from the RPC). Both gates accept an explicit opt-in flag.
336
352
  * Pin selection follows `input.network` (defaults to `testnet` so the
337
353
  * pre-mainnet callers keep working unchanged). */
338
- export async function runInstallPolicy(
339
- raw: unknown
340
- ): Promise<ToolResponse<BuildInstallPolicyResult>> {
354
+ export async function runInstallPolicy(raw: unknown): Promise<ToolResponse<InstallPolicyResult>> {
341
355
  const parsed = InstallPolicyInputSchema.safeParse(raw)
342
356
  if (!parsed.success) {
343
357
  return {
@@ -383,6 +397,27 @@ export async function runInstallPolicy(
383
397
  const predicateHash = createHash('sha256')
384
398
  .update(Buffer.from(encodedPredicate, 'base64'))
385
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
+
386
421
  const result = await buildInstallPolicyXdr({
387
422
  smartAccount: input.smartAccount,
388
423
  sourceAccount: input.sourceAccount,
@@ -394,7 +429,7 @@ export async function runInstallPolicy(
394
429
  rpc: rpcClient,
395
430
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
396
431
  })
397
- return { ok: true, data: result }
432
+ return { ok: true, data: authorityScan ? { ...result, authorityScan } : result }
398
433
  } catch (e) {
399
434
  return {
400
435
  ok: false,
@@ -403,6 +438,343 @@ export async function runInstallPolicy(
403
438
  }
404
439
  }
405
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
+
406
778
  /** `revoke_policy` body - thin wrapper over `buildRevokePolicyXdr`.
407
779
  * Emits an unsigned XDR for `account.remove_context_rule(ruleId)`; the
408
780
  * smart account itself handles uninstalling each attached policy. Auth
@@ -469,12 +841,12 @@ export async function runRevokePolicy(
469
841
  * fabricating it would be a lie on a security surface; the live
470
842
  * mismatch check is worth MORE).
471
843
  *
472
- * Network-aware: `input.network` selects which interpreter pin and RPC
473
- * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
474
- * uploaded to mainnet as was exercised on testnet, so a single
475
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks.
476
- * The address differs because instance ids are network-scoped.
477
- * UNAUDITED at the time of writing.
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.
478
850
  *
479
851
  * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
480
852
  * call, the auth-digest + the answer bind to whichever RPC answered, so
@@ -522,7 +894,7 @@ export async function runGetInterpreterInfo(
522
894
  const info = getInterpreterInfo({
523
895
  pinnedAddress,
524
896
  pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
525
- pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256,
897
+ pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK[network],
526
898
  network,
527
899
  ...(deployedGrammarVersion !== undefined ? { deployedGrammarVersion } : {}),
528
900
  })
@@ -176,6 +176,23 @@ export const ComposeUserResponsesSchema = z
176
176
  swapRecipientAllowlist: z
177
177
  .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
178
178
  .optional(),
179
+ // Per-asset oracle-price bound, one entry per asset. Reaches
180
+ // `compose-from-recording` and emits an `oracle_price` compare. Declared
181
+ // here because the object is `.passthrough()`: the field already worked
182
+ // undeclared, which left the accepted surface wider than the documented
183
+ // one. `decimals` is REQUIRED - oracle prices normalise to 9 dp and a
184
+ // threshold silently assumed to share that basis is what let a raw 14-dp
185
+ // bound permit everything.
186
+ oraclePriceBound: z
187
+ .array(
188
+ z.object({
189
+ asset: z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'),
190
+ operator: z.enum(['eq', 'lt', 'lte', 'gt', 'gte']),
191
+ value: z.string().regex(/^[0-9]+$/),
192
+ decimals: z.number().int().min(0).max(U32_MAX),
193
+ })
194
+ )
195
+ .optional(),
179
196
  })
180
197
  .passthrough()
181
198
 
@@ -502,20 +519,31 @@ const MandateSpecSchemaForRule = z
502
519
  /** Pinned interpreter address (testnet).
503
520
  * Single source for the MCP layer; do not embed elsewhere. */
504
521
  export const PINNED_INTERPRETER_TESTNET_ADDRESS =
505
- 'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP'
522
+ 'CALHNU4LXZRKFAXYRBODTGDANTNXHJPBTJNYGLUWLIFIP24NWSGWIE4K'
506
523
 
507
- /** Pinned interpreter address (mainnet). Mainnet
508
- * has now been deployed (2026-08-04); the mainnet interpreter IS the binary
509
- * that was exercised on testnet (same wasm sha256, see
510
- * PINNED_INTERPRETER_WASM_SHA256). The address differs because instance
511
- * ids are network-scoped. UNAUDITED at the time of writing. */
524
+ /** Pinned interpreter address (mainnet), deployed 2026-08-04.
525
+ * UNAUDITED at the time of writing. */
512
526
  export const PINNED_INTERPRETER_MAINNET_ADDRESS =
513
527
  'CALZAMUPREIRY4TULBEXIK77AUTOEJG63XLCPUWEHHQDOVK6ZVVS7VQ2'
514
528
 
515
- /** Pinned interpreter wasm sha256 (hex). */
529
+ /** Pinned interpreter wasm sha256 (hex), mainnet.
530
+ *
531
+ * The two networks no longer run the same binary. Testnet carries the
532
+ * selector-leaf minimum and the signer-set cap; mainnet predates both. Read
533
+ * the hash through `PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK` rather than
534
+ * this constant unless mainnet is specifically what is meant, or
535
+ * `get_interpreter_info` will report a hash the queried network does not
536
+ * run. */
516
537
  export const PINNED_INTERPRETER_WASM_SHA256 =
517
538
  '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443'
518
539
 
540
+ /** Pinned interpreter wasm sha256 (hex), testnet. Byte-identical to the
541
+ * artifact built from `contracts/policy-interpreter` at the commit that
542
+ * introduced errors 216 and 217, verified by fetching the deployed wasm back
543
+ * off chain. */
544
+ export const PINNED_INTERPRETER_WASM_SHA256_TESTNET =
545
+ 'a4d58bc88fd82bbb8e223941ba5889db919717fae92ced13f6f55bfe583b8e22'
546
+
519
547
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
520
548
  * contracts/policy-interpreter/src/version.rs). */
521
549
  export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1
@@ -532,14 +560,18 @@ export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org'
532
560
  * the deploy script hit during the 2026-08-04 mainnet rollout. */
533
561
  export const MAINNET_RPC_URL = 'https://mainnet.sorobanrpc.com'
534
562
 
535
- /** Pin + RPC lookup for the gate enforcement. The interpreters' wasm sha256
536
- * is identical across both networks (the same binary was uploaded both
537
- * places), so `PINNED_INTERPRETER_WASM_SHA256` stays
538
- * a single constant - only the addresses and RPCs are network-scoped. */
563
+ /** Pin + RPC lookup for the gate enforcement. Addresses, RPCs and wasm
564
+ * hashes are all network-scoped: the networks diverged when the
565
+ * selector-leaf and signer-cap controls were deployed to testnet ahead of
566
+ * mainnet. */
539
567
  export const PINNED_INTERPRETER_ADDRESS_BY_NETWORK: Record<Network, string> = {
540
568
  testnet: PINNED_INTERPRETER_TESTNET_ADDRESS,
541
569
  mainnet: PINNED_INTERPRETER_MAINNET_ADDRESS,
542
570
  }
571
+ export const PINNED_INTERPRETER_WASM_SHA256_BY_NETWORK: Record<Network, string> = {
572
+ testnet: PINNED_INTERPRETER_WASM_SHA256_TESTNET,
573
+ mainnet: PINNED_INTERPRETER_WASM_SHA256,
574
+ }
543
575
  export const RPC_URL_BY_NETWORK: Record<Network, string> = {
544
576
  testnet: TESTNET_RPC_URL,
545
577
  mainnet: MAINNET_RPC_URL,
@@ -607,6 +639,17 @@ export const InstallPolicyInputSchema = z
607
639
  * everything. Selecting `network: 'mainnet'` is NOT an opt-in -
608
640
  * the mainnet pin is its own deny-by-default anchor. */
609
641
  allowUnpinnedInterpreter: z.boolean().optional(),
642
+ /** Opt-in to installing when a signer of this rule can already reach the
643
+ * same calls through a context rule that has NO policy attached. OZ lets
644
+ * the signer choose which rule authorises a call and enforces only that
645
+ * rule's policies, so an unpoliced rule covering the same calls makes
646
+ * this policy decorative. Default-deny, because the caller almost
647
+ * certainly believes they are restricting something. */
648
+ allowAuthorityOverlap: z.boolean().optional(),
649
+ /** Skip the cross-rule authority scan entirely. The scan costs one RPC
650
+ * read per rule on the account; skipping it means the response carries
651
+ * no statement about what the signers can already do. */
652
+ skipAuthorityScan: z.boolean().optional(),
610
653
  /** Base fee in stroops; defaults to BASE_FEE (100). */
611
654
  baseFee: z.number().int().positive().optional(),
612
655
  })
@@ -615,6 +658,36 @@ export const InstallPolicyInputSchema = z
615
658
  })
616
659
  export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
617
660
 
661
+ /** `merge_policy` input.
662
+ *
663
+ * The tightening remedy for a cross-rule overlap: replace a rule's predicate
664
+ * with the conjunction of it and a new one. Two transactions, in order,
665
+ * because OZ refuses to re-attach a policy already on the rule - so the
666
+ * caller runs `detach`, waits for it to confirm, then runs `reinstall`. */
667
+ export const MergePolicyInputSchema = z
668
+ .object({
669
+ smartAccount: z
670
+ .string()
671
+ .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
672
+ sourceAccount: z
673
+ .string()
674
+ .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
675
+ /** The rule whose predicate is being tightened. */
676
+ ruleId: z.number().int().nonnegative(),
677
+ /** The predicate to conjoin, base64 canonical ScVal, as emitted by
678
+ * `synthesize_policy`. */
679
+ incomingPredicateBlobBase64: z.string().min(1),
680
+ /** Which half of the remedy to build. */
681
+ step: z.enum(['detach', 'reinstall']),
682
+ network: NetworkSchema.optional(),
683
+ rpcUrl: z.string().url().optional(),
684
+ allowUnpinnedRpcUrl: z.boolean().optional(),
685
+ baseFee: z.number().int().positive().optional(),
686
+ })
687
+ .strict()
688
+
689
+ export type MergePolicyInput = z.infer<typeof MergePolicyInputSchema>
690
+
618
691
  export const RevokePolicyInputSchema = z
619
692
  .object({
620
693
  /** The smart account contract address (C...). */