@crediolabs/policy-synth 0.1.14 → 0.1.16

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.
package/src/run/index.ts CHANGED
@@ -52,18 +52,18 @@ import {
52
52
  type InstallPolicyInput,
53
53
  InstallPolicyInputSchema,
54
54
  NETWORK_PASSPHRASES,
55
+ PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
55
56
  PINNED_INTERPRETER_GRAMMAR_VERSION,
56
- PINNED_INTERPRETER_TESTNET_ADDRESS,
57
57
  PINNED_INTERPRETER_WASM_SHA256,
58
58
  type RecordTransactionInput,
59
59
  RecordTransactionInputSchema,
60
60
  type RevokePolicyInput,
61
61
  RevokePolicyInputSchema,
62
+ RPC_URL_BY_NETWORK,
62
63
  type SimulatePolicyInput,
63
64
  SimulatePolicyInputSchema,
64
65
  type SynthesizePolicyInput,
65
66
  SynthesizePolicyInputSchema,
66
- TESTNET_RPC_URL,
67
67
  type VerifyPolicyInput,
68
68
  VerifyPolicyInputSchema,
69
69
  } from './schemas.ts'
@@ -86,11 +86,14 @@ export {
86
86
  GetInterpreterInfoInputSchema,
87
87
  InstallPolicyInputSchema,
88
88
  InterpreterOptionsSchema,
89
+ MAINNET_RPC_URL,
89
90
  MandateSpecSchema,
90
91
  NetworkSchema,
91
92
  OraclePriceFixtureSchema,
92
93
  OzAdapterConfigSchema,
94
+ PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
93
95
  PINNED_INTERPRETER_GRAMMAR_VERSION,
96
+ PINNED_INTERPRETER_MAINNET_ADDRESS,
94
97
  PINNED_INTERPRETER_TESTNET_ADDRESS,
95
98
  PINNED_INTERPRETER_WASM_SHA256,
96
99
  PredicateLeafSchema,
@@ -98,8 +101,10 @@ export {
98
101
  RecordedTransactionSchema,
99
102
  RecordTransactionInputSchema,
100
103
  RevokePolicyInputSchema,
104
+ RPC_URL_BY_NETWORK,
101
105
  SimulatePolicyInputSchema,
102
106
  SynthesizePolicyInputSchema,
107
+ TESTNET_RPC_URL,
103
108
  ToolErrorSchema,
104
109
  VerifyPolicyInputSchema,
105
110
  } from './schemas.ts'
@@ -300,15 +305,17 @@ export async function runVerifyPolicy(raw: unknown): Promise<ToolResponse<true>>
300
305
  * Returns the unsigned Soroban transaction envelope (base64 XDR) the
301
306
  * wallet signs. The wallet signature IS the user-confirmation step - no
302
307
  * `action_id` two-call pair (the server is stateless, see server.ts:10-12).
303
- * Per design decision 4, only CALL 1 (account.add_context_rule) is
304
- * emitted; CALL 2 (interpreter.install) requires the rule id the account
305
- * assigns in call 1 and is documented under `followUp` in the response.
308
+ * One call installs the policy outright: `add_context_rule` carries the
309
+ * predicate to the interpreter in its `policies` install_param, so no
310
+ * separate `interpreter.install` call is needed or possible.
306
311
  *
307
312
  * Default-deny: an interpreter policy address other than the pinned
308
- * testnet interpreter is REFUSED (the smart account would delegate to
309
- * an interpreter the caller controls); the same applies to a non-pinned
310
- * RPC URL (the auth nonce the wallet signs comes from the RPC). Both
311
- * gates accept an explicit opt-in flag. */
313
+ * interpreter for the selected network is REFUSED (the smart account
314
+ * would delegate to an interpreter the caller controls); the same
315
+ * applies to a non-pinned RPC URL (the auth nonce the wallet signs
316
+ * comes from the RPC). Both gates accept an explicit opt-in flag.
317
+ * Pin selection follows `input.network` (defaults to `testnet` so the
318
+ * pre-mainnet callers keep working unchanged). */
312
319
  export async function runInstallPolicy(
313
320
  raw: unknown
314
321
  ): Promise<ToolResponse<BuildInstallPolicyResult>> {
@@ -320,17 +327,24 @@ export async function runInstallPolicy(
320
327
  }
321
328
  }
322
329
  const input: InstallPolicyInput = parsed.data
330
+ const network: Network = input.network ?? 'testnet'
323
331
  // ---- Pinning gates (default-deny) ----
324
- const pinningError = enforceInterpreterPin(input.rule.policies, input.allowUnpinnedInterpreter)
332
+ const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
333
+ const expectedRpc = RPC_URL_BY_NETWORK[network]
334
+ const pinningError = enforceInterpreterPin(
335
+ input.rule.policies,
336
+ input.allowUnpinnedInterpreter,
337
+ expectedInterpreter
338
+ )
325
339
  if (pinningError) {
326
340
  return { ok: false, error: pinningError }
327
341
  }
328
- if (input.rpcUrl && input.rpcUrl !== TESTNET_RPC_URL && input.allowUnpinnedRpcUrl !== true) {
342
+ if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
329
343
  return {
330
344
  ok: false,
331
345
  error: {
332
346
  code: 'INSTALL_BUILD_FAILED',
333
- message: `install_policy: rpcUrl must equal the pinned TESTNET_RPC_URL; set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
347
+ message: `install_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
334
348
  severity: 'error',
335
349
  retryable: false,
336
350
  remediation: { toolCall: { name: 'install_policy', args: {} } },
@@ -339,7 +353,7 @@ export async function runInstallPolicy(
339
353
  }
340
354
  let rpcClient: InstallRpcClient
341
355
  try {
342
- rpcClient = buildRpcClientFromInput(input.rpcUrl)
356
+ rpcClient = buildRpcClientFromInput(input.rpcUrl, network)
343
357
  } catch (e) {
344
358
  return {
345
359
  ok: false,
@@ -355,7 +369,7 @@ export async function runInstallPolicy(
355
369
  const result = await buildInstallPolicyXdr({
356
370
  smartAccount: input.smartAccount,
357
371
  sourceAccount: input.sourceAccount,
358
- networkPassphrase: NETWORK_PASSPHRASES.testnet,
372
+ networkPassphrase: NETWORK_PASSPHRASES[network],
359
373
  rule: input.rule,
360
374
  installNonce: input.installNonce,
361
375
  encodedPredicate,
@@ -379,7 +393,8 @@ export async function runInstallPolicy(
379
393
  *
380
394
  * Same RPC pin as install: a non-pinned `rpcUrl` is refused unless
381
395
  * `allowUnpinnedRpcUrl: true`. Revoke does not carry an interpreter
382
- * policy payload, so the interpreter pin is not re-checked here. */
396
+ * policy payload, so the interpreter pin is not re-checked here.
397
+ * Pin selection follows `input.network` (defaults to `testnet`). */
383
398
  export async function runRevokePolicy(
384
399
  raw: unknown
385
400
  ): Promise<ToolResponse<BuildRevokePolicyResult>> {
@@ -391,12 +406,14 @@ export async function runRevokePolicy(
391
406
  }
392
407
  }
393
408
  const input: RevokePolicyInput = parsed.data
394
- if (input.rpcUrl && input.rpcUrl !== TESTNET_RPC_URL && input.allowUnpinnedRpcUrl !== true) {
409
+ const network: Network = input.network ?? 'testnet'
410
+ const expectedRpc = RPC_URL_BY_NETWORK[network]
411
+ if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
395
412
  return {
396
413
  ok: false,
397
414
  error: {
398
415
  code: 'REVOKE_BUILD_FAILED',
399
- message: `revoke_policy: rpcUrl must equal the pinned TESTNET_RPC_URL; set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
416
+ message: `revoke_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
400
417
  severity: 'error',
401
418
  retryable: false,
402
419
  remediation: { toolCall: { name: 'revoke_policy', args: {} } },
@@ -405,7 +422,7 @@ export async function runRevokePolicy(
405
422
  }
406
423
  let rpcClient: InstallRpcClient
407
424
  try {
408
- rpcClient = buildRpcClientFromInput(input.rpcUrl)
425
+ rpcClient = buildRpcClientFromInput(input.rpcUrl, network)
409
426
  } catch (e) {
410
427
  return {
411
428
  ok: false,
@@ -417,7 +434,7 @@ export async function runRevokePolicy(
417
434
  smartAccount: input.smartAccount,
418
435
  sourceAccount: input.sourceAccount,
419
436
  ruleId: input.ruleId,
420
- networkPassphrase: NETWORK_PASSPHRASES.testnet,
437
+ networkPassphrase: NETWORK_PASSPHRASES[network],
421
438
  rpc: rpcClient,
422
439
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
423
440
  })
@@ -435,7 +452,15 @@ export async function runRevokePolicy(
435
452
  * `grammar_version()` comparison. The audit field is deliberately
436
453
  * OMITTED (phase-04's "audit #44" has no source of truth in the repo -
437
454
  * fabricating it would be a lie on a security surface; the live
438
- * mismatch check is worth MORE). */
455
+ * mismatch check is worth MORE).
456
+ *
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. */
439
464
  export async function runGetInterpreterInfo(
440
465
  raw: unknown
441
466
  ): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>> {
@@ -448,37 +473,15 @@ export async function runGetInterpreterInfo(
448
473
  }
449
474
  const input: GetInterpreterInfoInput = parsed.data
450
475
  const network: Network = input.network ?? 'testnet'
451
- // Asking about mainnet used to return the TESTNET pin with `network:
452
- // 'mainnet'` stamped on it. DEPLOYMENTS.md:3 says plainly "Testnet only.
453
- // Nothing is deployed to mainnet yet", so that answer described a contract
454
- // that does not exist at an address that is not on that network. This tool
455
- // exists to tell a caller what they are installing against; inventing a
456
- // mainnet deployment is the same failure as inventing an audit reference.
457
- if (network === 'mainnet') {
458
- return {
459
- ok: false,
460
- error: {
461
- code: 'RECORDING_FAILED',
462
- message:
463
- 'get_interpreter_info: no interpreter is deployed to mainnet. The pinned address and wasm hash are testnet-only; mainnet deployment is gated on the interpreter audit.',
464
- severity: 'error',
465
- retryable: false,
466
- remediation: {
467
- toolCall: { name: 'get_interpreter_info', args: { network: 'testnet' } },
468
- },
469
- },
470
- }
471
- }
476
+ const pinnedAddress = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
472
477
  try {
473
478
  let deployedGrammarVersion: number | undefined
474
479
  if (input.verifyLive === true) {
475
- const rpcClient = buildRpcClientFromInput(input.rpcUrl)
476
- deployedGrammarVersion = await rpcClient.getContractVersion(
477
- PINNED_INTERPRETER_TESTNET_ADDRESS
478
- )
480
+ const rpcClient = buildRpcClientFromInput(input.rpcUrl, network)
481
+ deployedGrammarVersion = await rpcClient.getContractVersion(pinnedAddress)
479
482
  }
480
483
  const info = getInterpreterInfo({
481
- pinnedAddress: PINNED_INTERPRETER_TESTNET_ADDRESS,
484
+ pinnedAddress,
482
485
  pinnedGrammarVersion: PINNED_INTERPRETER_GRAMMAR_VERSION,
483
486
  pinnedWasmHash: PINNED_INTERPRETER_WASM_SHA256,
484
487
  network,
@@ -493,48 +496,45 @@ export async function runGetInterpreterInfo(
493
496
  }
494
497
  }
495
498
 
496
- /** Build an InstallRpcClient from an optional URL override, falling back
497
- * to the public testnet RPC (matching the design-decision rule that we
498
- * reuse `record/rpc.ts` and never hard-code a different RPC client). */
499
- function buildRpcClientFromInput(urlOverride?: string): InstallRpcClient {
500
- if (urlOverride) {
501
- return rpcClientFromServer(
502
- new rpc.Server(urlOverride, { allowHttp: false }),
503
- NETWORK_PASSPHRASES.testnet
504
- )
505
- }
506
- // Default: public testnet RPC. Matches the brief; the mainnet pin would
507
- // need a separate deploy and is out of scope for this tool.
508
- //
499
+ /** Build an InstallRpcClient from an optional URL override + selected
500
+ * network, falling back to the pinned RPC for the network. The caller
501
+ * has already been gated against the pinned URL elsewhere, so the
502
+ * fallback here only ever picks from a finite, audited pair. */
503
+ function buildRpcClientFromInput(
504
+ urlOverride: string | undefined,
505
+ network: Network
506
+ ): InstallRpcClient {
507
+ const url = urlOverride ?? RPC_URL_BY_NETWORK[network]
508
+ const passphrase = NETWORK_PASSPHRASES[network]
509
509
  // NOT `createRpcServer` - that returns an RpcFetcher, a bare
510
510
  // `(hash) => Promise<SorobanTxResponse|null>` for the RECORDER. Passing it
511
511
  // here produced a client whose `getAccount` was undefined, so every live
512
512
  // call died with "server.getAccount is not a function". The install path
513
513
  // needs the full Server surface.
514
- return rpcClientFromServer(
515
- new rpc.Server(TESTNET_RPC_URL, { allowHttp: false }),
516
- NETWORK_PASSPHRASES.testnet
517
- )
514
+ return rpcClientFromServer(new rpc.Server(url, { allowHttp: false }), passphrase)
518
515
  }
519
516
 
520
517
  /** Default-deny: refuse any interpreter policy whose address differs from
521
- * the pinned testnet interpreter. An interpreter the caller controls
522
- * can permit anything, so the smart account's authorization must bind
523
- * to the pinned contract unless the caller explicitly opts in via
524
- * `allowUnpinnedInterpreter`. OZ built-in policies are not interpreters
525
- * and pass through unchanged. Returns a ToolError to surface through
526
- * the run-layer envelope, or null when the policies are all pinned. */
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. */
527
526
  function enforceInterpreterPin(
528
527
  policies: InstallPolicyInput['rule']['policies'],
529
- allowUnpinned: boolean | undefined
528
+ allowUnpinned: boolean | undefined,
529
+ expectedInterpreterAddress: string
530
530
  ): ToolError | null {
531
531
  for (const p of policies) {
532
532
  if (p.kind !== 'interpreter') continue
533
- if (p.interpreterAddress === PINNED_INTERPRETER_TESTNET_ADDRESS) continue
533
+ if (p.interpreterAddress === expectedInterpreterAddress) continue
534
534
  if (allowUnpinned === true) continue
535
535
  return {
536
536
  code: 'INSTALL_BUILD_FAILED',
537
- message: `install_policy: interpreter policy address ${p.interpreterAddress} != pinned ${PINNED_INTERPRETER_TESTNET_ADDRESS}; set allowUnpinnedInterpreter: true to opt in to a non-pinned interpreter`,
537
+ message: `install_policy: interpreter policy address ${p.interpreterAddress} != pinned ${expectedInterpreterAddress}; set allowUnpinnedInterpreter: true to opt in to a non-pinned interpreter`,
538
538
  severity: 'error',
539
539
  retryable: false,
540
540
  remediation: { toolCall: { name: 'install_policy', args: {} } },
@@ -475,11 +475,20 @@ const MandateSpecSchemaForRule = z
475
475
  // reference resolves at module init. They are the new tools on top of the
476
476
  // existing four.
477
477
 
478
- /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:156-157.
478
+ /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:128.
479
479
  * Single source for the MCP layer; do not embed elsewhere. */
480
480
  export const PINNED_INTERPRETER_TESTNET_ADDRESS =
481
481
  'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP'
482
482
 
483
+ /** Pinned interpreter address (mainnet). Mirrors DEPLOYMENTS.md:18. Mainnet
484
+ * has now been deployed (2026-08-04); the mainnet interpreter IS the binary
485
+ * that was exercised on testnet (same wasm sha256, see
486
+ * PINNED_INTERPRETER_WASM_SHA256). The address differs because instance
487
+ * ids are network-scoped. UNAUDITED at the time of writing - see
488
+ * DEPLOYMENTS.md:29-32 for the recorded decision and what is still pending. */
489
+ export const PINNED_INTERPRETER_MAINNET_ADDRESS =
490
+ 'CALZAMUPREIRY4TULBEXIK77AUTOEJG63XLCPUWEHHQDOVK6ZVVS7VQ2'
491
+
483
492
  /** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
484
493
  export const PINNED_INTERPRETER_WASM_SHA256 =
485
494
  '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443'
@@ -494,6 +503,25 @@ export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1
494
503
  * pinned beside the addresses it is used with rather than shared. */
495
504
  export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org'
496
505
 
506
+ /** Mainnet Soroban RPC. Pinned beside MAINNET counterparts so the install
507
+ * and get_interpreter_info gates compare against the matching network's
508
+ * RPC rather than always testnet. Public mainnet endpoint, the same one
509
+ * the deploy script hit during the 2026-08-04 mainnet rollout. */
510
+ export const MAINNET_RPC_URL = 'https://mainnet.sorobanrpc.com'
511
+
512
+ /** Pin + RPC lookup for the gate enforcement. The interpreters' wasm sha256
513
+ * is identical across both networks (the same binary was uploaded both
514
+ * places; DEPLOYMENTS.md:25-27), so `PINNED_INTERPRETER_WASM_SHA256` stays
515
+ * a single constant - only the addresses and RPCs are network-scoped. */
516
+ export const PINNED_INTERPRETER_ADDRESS_BY_NETWORK: Record<Network, string> = {
517
+ testnet: PINNED_INTERPRETER_TESTNET_ADDRESS,
518
+ mainnet: PINNED_INTERPRETER_MAINNET_ADDRESS,
519
+ }
520
+ export const RPC_URL_BY_NETWORK: Record<Network, string> = {
521
+ testnet: TESTNET_RPC_URL,
522
+ mainnet: MAINNET_RPC_URL,
523
+ }
524
+
497
525
  /** Soroban `valid_until` window (ledgers) added to the latest ledger when
498
526
  * building the auth entry. ~25 minutes at 5s/ledger. */
499
527
  export const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
@@ -513,22 +541,34 @@ export const InstallPolicyInputSchema = z
513
541
  /** The signer that authorises the install (G... wallet). Used only for
514
542
  * sequence number + auth nonce simulation; never persisted, never signed. */
515
543
  sourceAccount: z.string(),
544
+ /** Target network for the install. Selects which interpreter pin and
545
+ * which RPC URL are valid by default. Defaults to `testnet` so the
546
+ * pre-mainnet callers keep working: they were always pointing at
547
+ * the testnet pin, so defaulting testnet here is the conservative
548
+ * continuation, NOT a silent fall-through that bypasses the gate.
549
+ * A caller that targets mainnet MUST set this to `mainnet` (the
550
+ * pin and RPC pin do not move by themselves). */
551
+ network: NetworkSchema.optional(),
516
552
  /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
517
553
  rule: MandateSpecSchemaForRule,
518
554
  /** Per-rule install nonce; 1 for a fresh install. */
519
555
  installNonce: z.number().int().positive(),
520
- /** Optional RPC URL override. Defaults to the public testnet endpoint
521
- * (`TESTNET_RPC_URL`); the override is refused unless
556
+ /** Optional RPC URL override. Defaults to the pinned RPC for the
557
+ * selected `network` (testnet by default, mainnet when
558
+ * `network: 'mainnet'`); the override is refused unless
522
559
  * `allowUnpinnedRpcUrl: true`, because the auth nonce and
523
560
  * rootInvocation in the response come from whichever RPC answered. */
524
561
  rpcUrl: z.string().url().optional(),
525
562
  /** Opt-in to using a non-pinned RPC URL. Without this flag the tool
526
- * refuses any `rpcUrl` other than `TESTNET_RPC_URL` because the
527
- * caller's auth-digest binds to whatever the RPC returned. */
563
+ * refuses any `rpcUrl` other than the pinned RPC for the selected
564
+ * network because the caller's auth-digest binds to whatever the
565
+ * RPC returned. */
528
566
  allowUnpinnedRpcUrl: z.boolean().optional(),
529
567
  /** Opt-in to pointing the rule's interpreter policy at any address
530
- * other than the pinned testnet interpreter. Default-deny: a caller
531
- * that controls the interpreter can permit everything. */
568
+ * other than the pinned interpreter for the selected network.
569
+ * Default-deny: a caller that controls the interpreter can permit
570
+ * everything. Selecting `network: 'mainnet'` is NOT an opt-in -
571
+ * the mainnet pin is its own deny-by-default anchor. */
532
572
  allowUnpinnedInterpreter: z.boolean().optional(),
533
573
  /** Base fee in stroops; defaults to BASE_FEE (100). */
534
574
  baseFee: z.number().int().positive().optional(),
@@ -547,9 +587,12 @@ export const RevokePolicyInputSchema = z
547
587
  * verify, since the account's source is not in this repo. Proven on
548
588
  * testnet: the account's deployer can revoke. */
549
589
  sourceAccount: z.string(),
590
+ /** Target network for the revoke. Same `testnet`-default as install,
591
+ * so pre-mainnet callers keep working without an explicit flag. */
592
+ network: NetworkSchema.optional(),
550
593
  /** The rule id to remove from the smart account + interpreter. */
551
594
  ruleId: z.number().int().nonnegative(),
552
- /** Optional RPC URL override; same pin as install. */
595
+ /** Optional RPC URL override; same per-network pin as install. */
553
596
  rpcUrl: z.string().url().optional(),
554
597
  /** Opt-in to using a non-pinned RPC URL. Same semantics as install. */
555
598
  allowUnpinnedRpcUrl: z.boolean().optional(),