@crediolabs/policy-synth 0.1.17 → 0.1.18

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 (91) hide show
  1. package/README.md +43 -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/build-add-context-rule.js +16 -45
  8. package/dist/install/build-install-policy.d.ts +12 -5
  9. package/dist/install/build-install-policy.js +32 -23
  10. package/dist/install/get-interpreter-info.js +2 -2
  11. package/dist/install/index.d.ts +1 -1
  12. package/dist/install/index.js +1 -1
  13. package/dist/install/oz-auth.js +5 -3
  14. package/dist/predicate/decode.js +1 -1
  15. package/dist/predicate/encode.js +171 -10
  16. package/dist/record/decode.js +5 -6
  17. package/dist/registry/protocols.d.ts +1 -1
  18. package/dist/registry/protocols.js +1 -1
  19. package/dist/review-card/builder.d.ts +6 -0
  20. package/dist/review-card/builder.js +9 -1
  21. package/dist/run/index.d.ts +14 -13
  22. package/dist/run/index.js +80 -67
  23. package/dist/run/schemas.d.ts +193 -11
  24. package/dist/run/schemas.js +59 -17
  25. package/dist/synth/compose-from-recording.d.ts +10 -15
  26. package/dist/synth/compose-from-recording.js +79 -124
  27. package/dist/synth/deny-cases.d.ts +5 -0
  28. package/dist/synth/deny-cases.js +52 -13
  29. package/dist/synth/evaluate.js +69 -119
  30. package/dist/synth/harness.d.ts +13 -1
  31. package/dist/synth/harness.js +22 -1
  32. package/dist/synth/index.d.ts +1 -1
  33. package/dist/synth/synthesize-from-recording.d.ts +31 -21
  34. package/dist/synth/synthesize-from-recording.js +129 -155
  35. package/dist-cjs/adapters/interpreter/adapter.d.ts +6 -7
  36. package/dist-cjs/adapters/interpreter/adapter.js +28 -48
  37. package/dist-cjs/adapters/oz/adapter.js +12 -13
  38. package/dist-cjs/codegen/compile-gate.js +5 -0
  39. package/dist-cjs/codegen/template.js +17 -0
  40. package/dist-cjs/install/build-add-context-rule.js +16 -45
  41. package/dist-cjs/install/build-install-policy.d.ts +12 -5
  42. package/dist-cjs/install/build-install-policy.js +32 -23
  43. package/dist-cjs/install/get-interpreter-info.js +2 -2
  44. package/dist-cjs/install/index.d.ts +1 -1
  45. package/dist-cjs/install/index.js +2 -2
  46. package/dist-cjs/install/oz-auth.js +5 -3
  47. package/dist-cjs/predicate/decode.js +1 -1
  48. package/dist-cjs/predicate/encode.js +171 -10
  49. package/dist-cjs/record/decode.js +5 -6
  50. package/dist-cjs/registry/protocols.d.ts +1 -1
  51. package/dist-cjs/registry/protocols.js +1 -1
  52. package/dist-cjs/review-card/builder.d.ts +6 -0
  53. package/dist-cjs/review-card/builder.js +9 -1
  54. package/dist-cjs/run/index.d.ts +14 -13
  55. package/dist-cjs/run/index.js +80 -67
  56. package/dist-cjs/run/schemas.d.ts +193 -11
  57. package/dist-cjs/run/schemas.js +59 -17
  58. package/dist-cjs/synth/compose-from-recording.d.ts +10 -15
  59. package/dist-cjs/synth/compose-from-recording.js +79 -124
  60. package/dist-cjs/synth/deny-cases.d.ts +5 -0
  61. package/dist-cjs/synth/deny-cases.js +52 -13
  62. package/dist-cjs/synth/evaluate.js +69 -119
  63. package/dist-cjs/synth/harness.d.ts +13 -1
  64. package/dist-cjs/synth/harness.js +22 -1
  65. package/dist-cjs/synth/index.d.ts +1 -1
  66. package/dist-cjs/synth/synthesize-from-recording.d.ts +31 -21
  67. package/dist-cjs/synth/synthesize-from-recording.js +129 -153
  68. package/package.json +1 -1
  69. package/src/adapters/interpreter/adapter.ts +28 -48
  70. package/src/adapters/oz/adapter.ts +12 -13
  71. package/src/codegen/compile-gate.ts +5 -0
  72. package/src/codegen/template.ts +17 -0
  73. package/src/install/build-add-context-rule.ts +17 -55
  74. package/src/install/build-install-policy.ts +41 -23
  75. package/src/install/get-interpreter-info.ts +2 -2
  76. package/src/install/index.ts +2 -2
  77. package/src/install/oz-auth.ts +5 -3
  78. package/src/predicate/decode.ts +1 -1
  79. package/src/predicate/encode.ts +176 -10
  80. package/src/record/decode.ts +5 -6
  81. package/src/registry/protocols.ts +1 -1
  82. package/src/review-card/builder.ts +17 -1
  83. package/src/run/index.ts +117 -91
  84. package/src/run/schemas.ts +64 -16
  85. package/src/synth/compose-from-recording.ts +87 -132
  86. package/src/synth/deny-cases.ts +59 -13
  87. package/src/synth/evaluate.ts +70 -118
  88. package/src/synth/harness.ts +29 -1
  89. package/src/synth/index.ts +1 -0
  90. package/src/synth/synthesize-from-recording.ts +209 -192
  91. package/src/contracts/policy-template/OZ_POLICY_TRAIT.md +0 -196
package/dist/run/index.js CHANGED
@@ -28,6 +28,18 @@ import { GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPH
28
28
  // module that owns the tool-body glue. The strict schemas are the source of
29
29
  // truth - MCP tool shapes are derived from them.
30
30
  export { ComposeUserResponsesSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, InterpreterOptionsSchema, MAINNET_RPC_URL, MandateSpecSchema, NetworkSchema, OraclePriceFixtureSchema, OzAdapterConfigSchema, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_MAINNET_ADDRESS, PINNED_INTERPRETER_TESTNET_ADDRESS, PINNED_INTERPRETER_WASM_SHA256, PredicateLeafSchema, PredicateNodeSchema, RecordedTransactionSchema, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, TESTNET_RPC_URL, ToolErrorSchema, VerifyPolicyInputSchema, } from "./schemas.js";
31
+ /** Map every tool name to its canonical domain error code. Replaces a 7-way
32
+ * if/else so adding a tool adds one line here rather than a new branch in
33
+ * each envelope call. */
34
+ const TOOL_ERROR_CODE = {
35
+ record_transaction: 'RECORDING_FAILED',
36
+ synthesize_policy: 'SYNTHESIS_ERROR',
37
+ simulate_policy: 'SIMULATION_ERROR',
38
+ verify_policy: 'VERIFICATION_FAILED',
39
+ install_policy: 'INSTALL_BUILD_FAILED',
40
+ revoke_policy: 'REVOKE_BUILD_FAILED',
41
+ get_interpreter_info: 'RECORDING_FAILED',
42
+ };
31
43
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
32
44
  * matches the core RecordInput minus the injected `fetcher` (the transport
33
45
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -222,17 +234,9 @@ export async function runInstallPolicy(raw) {
222
234
  if (pinningError) {
223
235
  return { ok: false, error: pinningError };
224
236
  }
225
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
226
- return {
227
- ok: false,
228
- error: {
229
- code: 'INSTALL_BUILD_FAILED',
230
- message: `install_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
231
- severity: 'error',
232
- retryable: false,
233
- remediation: { toolCall: { name: 'install_policy', args: {} } },
234
- },
235
- };
237
+ const rpcPinningError = enforceRpcPin('install_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
238
+ if (rpcPinningError) {
239
+ return { ok: false, error: rpcPinningError };
236
240
  }
237
241
  let rpcClient;
238
242
  try {
@@ -290,17 +294,9 @@ export async function runRevokePolicy(raw) {
290
294
  const input = parsed.data;
291
295
  const network = input.network ?? 'testnet';
292
296
  const expectedRpc = RPC_URL_BY_NETWORK[network];
293
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
294
- return {
295
- ok: false,
296
- error: {
297
- code: 'REVOKE_BUILD_FAILED',
298
- message: `revoke_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
299
- severity: 'error',
300
- retryable: false,
301
- remediation: { toolCall: { name: 'revoke_policy', args: {} } },
302
- },
303
- };
297
+ const rpcPinningError = enforceRpcPin('revoke_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
298
+ if (rpcPinningError) {
299
+ return { ok: false, error: rpcPinningError };
304
300
  }
305
301
  let rpcClient;
306
302
  try {
@@ -340,10 +336,15 @@ export async function runRevokePolicy(raw) {
340
336
  * Network-aware: `input.network` selects which interpreter pin and RPC
341
337
  * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
342
338
  * uploaded to mainnet as was exercised on testnet, so a single
343
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks
344
- * (DEPLOYMENTS.md:25-27). The address differs because instance ids are
345
- * network-scoped. UNAUDITED at the time of writing - see
346
- * DEPLOYMENTS.md:29-32 for what is still pending. */
339
+ * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks.
340
+ * The address differs because instance ids are network-scoped.
341
+ * UNAUDITED at the time of writing.
342
+ *
343
+ * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
344
+ * call, the auth-digest + the answer bind to whichever RPC answered, so
345
+ * a non-pinned `rpcUrl` would silently bind the caller to a host they
346
+ * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
347
+ * opt-in as install/revoke. */
347
348
  export async function runGetInterpreterInfo(raw) {
348
349
  const parsed = GetInterpreterInfoInputSchema.safeParse(raw);
349
350
  if (!parsed.success) {
@@ -354,6 +355,19 @@ export async function runGetInterpreterInfo(raw) {
354
355
  }
355
356
  const input = parsed.data;
356
357
  const network = input.network ?? 'testnet';
358
+ const expectedRpc = RPC_URL_BY_NETWORK[network];
359
+ // Pin enforcement only matters when the live RPC call is engaged. The
360
+ // static-fingerprint branch is local data and a non-pinned URL never
361
+ // reaches the network, so the gate is conditioned on `verifyLive` to
362
+ // match the wire-level concern (an outbound request that returns a
363
+ // caller-bound value). A caller that DOES NOT verifyLive can supply any
364
+ // URL it likes; the field is still optional.
365
+ if (input.verifyLive === true) {
366
+ const rpcPinningError = enforceRpcPin('get_interpreter_info', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
367
+ if (rpcPinningError) {
368
+ return { ok: false, error: rpcPinningError };
369
+ }
370
+ }
357
371
  const pinnedAddress = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
358
372
  try {
359
373
  let deployedGrammarVersion;
@@ -391,15 +405,14 @@ function buildRpcClientFromInput(urlOverride, network) {
391
405
  // needs the full Server surface.
392
406
  return rpcClientFromServer(new rpc.Server(url, { allowHttp: false }), passphrase);
393
407
  }
394
- /** Default-deny: refuse any interpreter policy whose address differs from
395
- * the pinned interpreter for the selected network. An interpreter the
396
- * caller controls can permit anything, so the smart account's
397
- * authorization must bind to the pinned contract unless the caller
398
- * explicitly opts in via `allowUnpinnedInterpreter`. OZ built-in
399
- * policies are not interpreters and pass through unchanged. Returns a
400
- * ToolError to surface through the run-layer envelope, or null when the
401
- * policies are all pinned. The caller resolves the expected pin per
402
- * network; this function stays pure so it is easy to test. */
408
+ /** Default-deny: refuse an interpreter policy whose address differs from the
409
+ * pinned interpreter for the selected network. An interpreter the caller
410
+ * controls can permit anything, so the smart account's authorization must
411
+ * bind to the pinned contract unless the caller opts in. OZ built-in
412
+ * policies are not interpreters and pass through. Returns a ToolError to
413
+ * surface through the run-layer envelope, or null when all interpreter
414
+ * policies are pinned. The caller resolves the expected pin per network;
415
+ * this function stays pure so it is easy to test. */
403
416
  function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddress) {
404
417
  for (const p of policies) {
405
418
  if (p.kind !== 'interpreter')
@@ -418,58 +431,58 @@ function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddre
418
431
  }
419
432
  return null;
420
433
  }
434
+ /** Default-deny: refuse an `rpcUrl` that is not the pinned RPC for the
435
+ * selected network. The auth nonce + rootInvocation in the install/revoke
436
+ * response come from whichever RPC answered, so a non-pinned RPC would
437
+ * silently bind the caller to a host they picked. The same applies to
438
+ * `get_interpreter_info` when `verifyLive` is true (the live grammar
439
+ * version is the caller-bound value). The caller may opt in via
440
+ * `allowUnpinnedRpcUrl: true`. Returns a ToolError or null when the URL is
441
+ * pinned (or absent, since the default is the pinned one). */
442
+ function enforceRpcPin(toolName, rpcUrl, allowUnpinned, expectedRpc, network) {
443
+ if (!rpcUrl || rpcUrl === expectedRpc || allowUnpinned === true)
444
+ return null;
445
+ const code = toolName === 'install_policy'
446
+ ? 'INSTALL_BUILD_FAILED'
447
+ : toolName === 'revoke_policy'
448
+ ? 'REVOKE_BUILD_FAILED'
449
+ : 'RECORDING_FAILED';
450
+ return {
451
+ code,
452
+ message: `${toolName}: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
453
+ severity: 'error',
454
+ retryable: false,
455
+ remediation: { toolCall: { name: toolName, args: {} } },
456
+ };
457
+ }
421
458
  /** Build a canonical ToolError for a Zod validation failure. The remediation
422
459
  * hint points the agent back at the right tool with an empty arg bag - the
423
460
  * tool name IS the machine-readable hint. */
424
461
  function validationError(toolName, issues) {
425
- const code = toolName === 'record_transaction'
426
- ? 'RECORDING_FAILED'
427
- : toolName === 'synthesize_policy'
428
- ? 'SYNTHESIS_ERROR'
429
- : toolName === 'simulate_policy'
430
- ? 'SIMULATION_ERROR'
431
- : toolName === 'verify_policy'
432
- ? 'VERIFICATION_FAILED'
433
- : toolName === 'install_policy'
434
- ? 'INSTALL_BUILD_FAILED'
435
- : toolName === 'revoke_policy'
436
- ? 'REVOKE_BUILD_FAILED'
437
- : 'RECORDING_FAILED';
438
462
  return {
439
- code,
463
+ code: TOOL_ERROR_CODE[toolName],
440
464
  message: `${toolName}: invalid input: ${issues
441
465
  .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
442
466
  .join('; ')}`,
443
467
  severity: 'error',
444
468
  retryable: false,
445
- remediation: {
446
- toolCall: { name: toolName, args: {} },
447
- },
469
+ remediation: { toolCall: { name: toolName, args: {} } },
448
470
  };
449
471
  }
450
472
  /** Build a canonical ToolError for a thrown exception caught by the tool
451
473
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
452
474
  * default, so we extract a string-friendly message and tag the original
453
- * error in `details` for the agent to inspect. The `code` is the tool's
454
- * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
455
- * for `synthesize_policy`) so the agent dispatches on the same code the
456
- * structured ToolError would carry.
457
- *
458
- * Exported as `_caughtError` (the leading underscore signals the test-only
459
- * seam) so the suite in run/index.test.ts can drive the envelope path
460
- * without standing up a full recordTransaction pipeline. */
475
+ * error in `details` for the agent to inspect. Exported as a test-only seam
476
+ * so the suite in run/index.test.ts can drive the envelope path without
477
+ * standing up a full recordTransaction pipeline. */
461
478
  export function caughtError(toolName, code, e) {
462
- const message = describeThrown(e, toolName);
463
- const details = { thrown: safeStringify(e) };
464
479
  return {
465
480
  code,
466
- message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
481
+ message: `${toolName}: unhandled throw escaped core envelope: ${describeThrown(e, toolName)}`,
467
482
  severity: 'error',
468
483
  retryable: false,
469
- remediation: {
470
- toolCall: { name: toolName, args: {} },
471
- },
472
- details,
484
+ remediation: { toolCall: { name: toolName, args: {} } },
485
+ details: { thrown: safeStringify(e) },
473
486
  };
474
487
  }
475
488
  /** Build a human-readable message for an unknown caught value. Order matters:
@@ -352,7 +352,7 @@ export declare const RecordedTransactionSchema: z.ZodObject<{
352
352
  /** MandateSpec mirrors the core MandateSpec. The deterministic Mandate
353
353
  * front-end needs no parseConfidence; the tool adapter injects the full
354
354
  * confidence after synthesis so the orchestrator can compare. */
355
- export declare const MandateSpecSchema: z.ZodObject<{
355
+ export declare const MandateSpecSchema: z.ZodEffects<z.ZodObject<{
356
356
  chain: z.ZodLiteral<"stellar">;
357
357
  contract: z.ZodString;
358
358
  method: z.ZodOptional<z.ZodString>;
@@ -439,6 +439,64 @@ export declare const MandateSpecSchema: z.ZodObject<{
439
439
  validUntilLedger?: number | undefined;
440
440
  validUntilUnixSeconds?: number | undefined;
441
441
  }>>;
442
+ }, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{
443
+ chain: z.ZodLiteral<"stellar">;
444
+ contract: z.ZodString;
445
+ method: z.ZodOptional<z.ZodString>;
446
+ spendingLimit: z.ZodOptional<z.ZodObject<{
447
+ token: z.ZodString;
448
+ limit: z.ZodString;
449
+ windowSeconds: z.ZodNumber;
450
+ }, "strip", z.ZodTypeAny, {
451
+ token: string;
452
+ limit: string;
453
+ windowSeconds: number;
454
+ }, {
455
+ token: string;
456
+ limit: string;
457
+ windowSeconds: number;
458
+ }>>;
459
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
460
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
461
+ expiry: z.ZodOptional<z.ZodObject<{
462
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
463
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
464
+ }, "strip", z.ZodTypeAny, {
465
+ validUntilLedger?: number | undefined;
466
+ validUntilUnixSeconds?: number | undefined;
467
+ }, {
468
+ validUntilLedger?: number | undefined;
469
+ validUntilUnixSeconds?: number | undefined;
470
+ }>>;
471
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
472
+ chain: z.ZodLiteral<"stellar">;
473
+ contract: z.ZodString;
474
+ method: z.ZodOptional<z.ZodString>;
475
+ spendingLimit: z.ZodOptional<z.ZodObject<{
476
+ token: z.ZodString;
477
+ limit: z.ZodString;
478
+ windowSeconds: z.ZodNumber;
479
+ }, "strip", z.ZodTypeAny, {
480
+ token: string;
481
+ limit: string;
482
+ windowSeconds: number;
483
+ }, {
484
+ token: string;
485
+ limit: string;
486
+ windowSeconds: number;
487
+ }>>;
488
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
489
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
490
+ expiry: z.ZodOptional<z.ZodObject<{
491
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
492
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
493
+ }, "strip", z.ZodTypeAny, {
494
+ validUntilLedger?: number | undefined;
495
+ validUntilUnixSeconds?: number | undefined;
496
+ }, {
497
+ validUntilLedger?: number | undefined;
498
+ validUntilUnixSeconds?: number | undefined;
499
+ }>>;
442
500
  }, z.ZodTypeAny, "passthrough">>;
443
501
  /** ComposeUserResponses mirrors the core. */
444
502
  export declare const ComposeUserResponsesSchema: z.ZodObject<{
@@ -530,7 +588,7 @@ export declare const RecordTransactionInputSchema: z.ZodEffects<z.ZodEffects<z.Z
530
588
  export type RecordTransactionInput = z.infer<typeof RecordTransactionInputSchema>;
531
589
  export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
532
590
  source: z.ZodLiteral<"mandate">;
533
- mandate: z.ZodObject<{
591
+ mandate: z.ZodEffects<z.ZodObject<{
534
592
  chain: z.ZodLiteral<"stellar">;
535
593
  contract: z.ZodString;
536
594
  method: z.ZodOptional<z.ZodString>;
@@ -617,6 +675,64 @@ export declare const SynthesizePolicyMandateInputSchema: z.ZodObject<{
617
675
  validUntilLedger?: number | undefined;
618
676
  validUntilUnixSeconds?: number | undefined;
619
677
  }>>;
678
+ }, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{
679
+ chain: z.ZodLiteral<"stellar">;
680
+ contract: z.ZodString;
681
+ method: z.ZodOptional<z.ZodString>;
682
+ spendingLimit: z.ZodOptional<z.ZodObject<{
683
+ token: z.ZodString;
684
+ limit: z.ZodString;
685
+ windowSeconds: z.ZodNumber;
686
+ }, "strip", z.ZodTypeAny, {
687
+ token: string;
688
+ limit: string;
689
+ windowSeconds: number;
690
+ }, {
691
+ token: string;
692
+ limit: string;
693
+ windowSeconds: number;
694
+ }>>;
695
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
696
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
697
+ expiry: z.ZodOptional<z.ZodObject<{
698
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
699
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
700
+ }, "strip", z.ZodTypeAny, {
701
+ validUntilLedger?: number | undefined;
702
+ validUntilUnixSeconds?: number | undefined;
703
+ }, {
704
+ validUntilLedger?: number | undefined;
705
+ validUntilUnixSeconds?: number | undefined;
706
+ }>>;
707
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
708
+ chain: z.ZodLiteral<"stellar">;
709
+ contract: z.ZodString;
710
+ method: z.ZodOptional<z.ZodString>;
711
+ spendingLimit: z.ZodOptional<z.ZodObject<{
712
+ token: z.ZodString;
713
+ limit: z.ZodString;
714
+ windowSeconds: z.ZodNumber;
715
+ }, "strip", z.ZodTypeAny, {
716
+ token: string;
717
+ limit: string;
718
+ windowSeconds: number;
719
+ }, {
720
+ token: string;
721
+ limit: string;
722
+ windowSeconds: number;
723
+ }>>;
724
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
725
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
726
+ expiry: z.ZodOptional<z.ZodObject<{
727
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
728
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
729
+ }, "strip", z.ZodTypeAny, {
730
+ validUntilLedger?: number | undefined;
731
+ validUntilUnixSeconds?: number | undefined;
732
+ }, {
733
+ validUntilLedger?: number | undefined;
734
+ validUntilUnixSeconds?: number | undefined;
735
+ }>>;
620
736
  }, z.ZodTypeAny, "passthrough">>;
621
737
  ozConfig: z.ZodOptional<z.ZodObject<{
622
738
  network: z.ZodEnum<["mainnet", "testnet"]>;
@@ -1222,7 +1338,7 @@ export declare const SynthesizePolicyRecordingInputSchema: z.ZodObject<{
1222
1338
  }>;
1223
1339
  export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"source", [z.ZodObject<{
1224
1340
  source: z.ZodLiteral<"mandate">;
1225
- mandate: z.ZodObject<{
1341
+ mandate: z.ZodEffects<z.ZodObject<{
1226
1342
  chain: z.ZodLiteral<"stellar">;
1227
1343
  contract: z.ZodString;
1228
1344
  method: z.ZodOptional<z.ZodString>;
@@ -1309,6 +1425,64 @@ export declare const SynthesizePolicyInputSchema: z.ZodDiscriminatedUnion<"sourc
1309
1425
  validUntilLedger?: number | undefined;
1310
1426
  validUntilUnixSeconds?: number | undefined;
1311
1427
  }>>;
1428
+ }, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{
1429
+ chain: z.ZodLiteral<"stellar">;
1430
+ contract: z.ZodString;
1431
+ method: z.ZodOptional<z.ZodString>;
1432
+ spendingLimit: z.ZodOptional<z.ZodObject<{
1433
+ token: z.ZodString;
1434
+ limit: z.ZodString;
1435
+ windowSeconds: z.ZodNumber;
1436
+ }, "strip", z.ZodTypeAny, {
1437
+ token: string;
1438
+ limit: string;
1439
+ windowSeconds: number;
1440
+ }, {
1441
+ token: string;
1442
+ limit: string;
1443
+ windowSeconds: number;
1444
+ }>>;
1445
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
1446
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1447
+ expiry: z.ZodOptional<z.ZodObject<{
1448
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
1449
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
1450
+ }, "strip", z.ZodTypeAny, {
1451
+ validUntilLedger?: number | undefined;
1452
+ validUntilUnixSeconds?: number | undefined;
1453
+ }, {
1454
+ validUntilLedger?: number | undefined;
1455
+ validUntilUnixSeconds?: number | undefined;
1456
+ }>>;
1457
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
1458
+ chain: z.ZodLiteral<"stellar">;
1459
+ contract: z.ZodString;
1460
+ method: z.ZodOptional<z.ZodString>;
1461
+ spendingLimit: z.ZodOptional<z.ZodObject<{
1462
+ token: z.ZodString;
1463
+ limit: z.ZodString;
1464
+ windowSeconds: z.ZodNumber;
1465
+ }, "strip", z.ZodTypeAny, {
1466
+ token: string;
1467
+ limit: string;
1468
+ windowSeconds: number;
1469
+ }, {
1470
+ token: string;
1471
+ limit: string;
1472
+ windowSeconds: number;
1473
+ }>>;
1474
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
1475
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1476
+ expiry: z.ZodOptional<z.ZodObject<{
1477
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
1478
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
1479
+ }, "strip", z.ZodTypeAny, {
1480
+ validUntilLedger?: number | undefined;
1481
+ validUntilUnixSeconds?: number | undefined;
1482
+ }, {
1483
+ validUntilLedger?: number | undefined;
1484
+ validUntilUnixSeconds?: number | undefined;
1485
+ }>>;
1312
1486
  }, z.ZodTypeAny, "passthrough">>;
1313
1487
  ozConfig: z.ZodOptional<z.ZodObject<{
1314
1488
  network: z.ZodEnum<["mainnet", "testnet"]>;
@@ -2641,20 +2815,19 @@ export declare const VerifyPolicyInputSchema: z.ZodObject<{
2641
2815
  }> | undefined;
2642
2816
  }>;
2643
2817
  export type VerifyPolicyInput = z.infer<typeof VerifyPolicyInputSchema>;
2644
- /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:128.
2818
+ /** Pinned interpreter address (testnet).
2645
2819
  * Single source for the MCP layer; do not embed elsewhere. */
2646
2820
  export declare const PINNED_INTERPRETER_TESTNET_ADDRESS = "CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP";
2647
- /** Pinned interpreter address (mainnet). Mirrors DEPLOYMENTS.md:18. Mainnet
2821
+ /** Pinned interpreter address (mainnet). Mainnet
2648
2822
  * has now been deployed (2026-08-04); the mainnet interpreter IS the binary
2649
2823
  * that was exercised on testnet (same wasm sha256, see
2650
2824
  * PINNED_INTERPRETER_WASM_SHA256). The address differs because instance
2651
- * ids are network-scoped. UNAUDITED at the time of writing - see
2652
- * DEPLOYMENTS.md:29-32 for the recorded decision and what is still pending. */
2825
+ * ids are network-scoped. UNAUDITED at the time of writing. */
2653
2826
  export declare const PINNED_INTERPRETER_MAINNET_ADDRESS = "CALZAMUPREIRY4TULBEXIK77AUTOEJG63XLCPUWEHHQDOVK6ZVVS7VQ2";
2654
- /** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
2827
+ /** Pinned interpreter wasm sha256 (hex). */
2655
2828
  export declare const PINNED_INTERPRETER_WASM_SHA256 = "6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443";
2656
2829
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
2657
- * packages/policy-interpreter/src/version.rs). */
2830
+ * contracts/policy-interpreter/src/version.rs). */
2658
2831
  export declare const PINNED_INTERPRETER_GRAMMAR_VERSION = 1;
2659
2832
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
2660
2833
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
@@ -2668,7 +2841,7 @@ export declare const TESTNET_RPC_URL = "https://soroban-testnet.stellar.org";
2668
2841
  export declare const MAINNET_RPC_URL = "https://mainnet.sorobanrpc.com";
2669
2842
  /** Pin + RPC lookup for the gate enforcement. The interpreters' wasm sha256
2670
2843
  * is identical across both networks (the same binary was uploaded both
2671
- * places; DEPLOYMENTS.md:25-27), so `PINNED_INTERPRETER_WASM_SHA256` stays
2844
+ * places), so `PINNED_INTERPRETER_WASM_SHA256` stays
2672
2845
  * a single constant - only the addresses and RPCs are network-scoped. */
2673
2846
  export declare const PINNED_INTERPRETER_ADDRESS_BY_NETWORK: Record<Network, string>;
2674
2847
  export declare const RPC_URL_BY_NETWORK: Record<Network, string>;
@@ -3403,15 +3576,24 @@ export declare const GetInterpreterInfoInputSchema: z.ZodObject<{
3403
3576
  /** When true, perform an optional live `grammar_version()` RPC call to
3404
3577
  * verify the deployed contract matches the pin. */
3405
3578
  verifyLive: z.ZodOptional<z.ZodBoolean>;
3406
- /** Optional RPC URL override. */
3579
+ /** Optional RPC URL override. When `verifyLive` is true, the URL must
3580
+ * equal the pinned RPC for the selected network unless
3581
+ * `allowUnpinnedRpcUrl: true` is also set (same opt-in shape as
3582
+ * install/revoke - the live grammar_version() answer binds to whichever
3583
+ * RPC returned it). */
3407
3584
  rpcUrl: z.ZodOptional<z.ZodString>;
3585
+ /** Opt-in to using a non-pinned RPC URL when `verifyLive` is true. Same
3586
+ * semantics as install/revoke: the caller accepts the trust shift. */
3587
+ allowUnpinnedRpcUrl: z.ZodOptional<z.ZodBoolean>;
3408
3588
  }, "strip", z.ZodTypeAny, {
3409
3589
  network?: "mainnet" | "testnet" | undefined;
3410
3590
  rpcUrl?: string | undefined;
3591
+ allowUnpinnedRpcUrl?: boolean | undefined;
3411
3592
  verifyLive?: boolean | undefined;
3412
3593
  }, {
3413
3594
  network?: "mainnet" | "testnet" | undefined;
3414
3595
  rpcUrl?: string | undefined;
3596
+ allowUnpinnedRpcUrl?: boolean | undefined;
3415
3597
  verifyLive?: boolean | undefined;
3416
3598
  }>;
3417
3599
  export type GetInterpreterInfoInput = z.infer<typeof GetInterpreterInfoInputSchema>;
@@ -7,9 +7,9 @@
7
7
  // step with the TS source of truth.
8
8
  //
9
9
  // i128 amounts and other large integers are carried as base-10 decimal strings
10
- // end-to-end (no JS number coercion). Networks are pinned to the same closed
11
- // set the core defines. The discriminated union on `source` exposes BOTH
12
- // synthesize_policy front-ends through a single tool input.
10
+ // end-to-end. Networks are pinned to the same closed set the core defines.
11
+ // The discriminated union on `source` exposes BOTH synthesize_policy
12
+ // front-ends through a single tool input.
13
13
  //
14
14
  // This module is the SINGLE source of truth for these shapes. The MCP package
15
15
  // imports them here so its tool-shape bindings stay in step; the CLI imports
@@ -123,7 +123,21 @@ export const MandateSpecSchema = z
123
123
  })
124
124
  .optional(),
125
125
  })
126
- .passthrough();
126
+ .passthrough()
127
+ // TS-F4/F6: a `recipients` allowlist is only meaningful against a SEP-41
128
+ // method whose arg 1 IS the recipient (SAC/SEP-41 `transfer(from, to,
129
+ // amount)` and SEP-41 `mint(to, amount)`). Without this gate the
130
+ // `to-ir.ts` lowering pins the allowlist to `RECIPIENT_ARG_INDEX = 1`
131
+ // for any contract+method, which would let a non-SEP-41 method's
132
+ // arg[1] (e.g. an amount, an op type, an arbitrary address payload)
133
+ // be silently constrained as if it were a recipient. Refusing
134
+ // non-SEP-41 methods at the boundary is the fail-closed shape.
135
+ .refine((v) => v.recipients === undefined ||
136
+ v.recipients.length === 0 ||
137
+ v.method === 'transfer' ||
138
+ v.method === 'mint', {
139
+ message: 'recipients is only valid when method is a SEP-41 method (transfer or mint); other methods do not have a recipient at arg[1]',
140
+ });
127
141
  /** ComposeUserResponses mirrors the core. */
128
142
  export const ComposeUserResponsesSchema = z
129
143
  .object({
@@ -264,7 +278,12 @@ export const PredicateLeafSchema = z.lazy(() => z.union([
264
278
  windowSeconds: z.number().int().positive(),
265
279
  }),
266
280
  z.object({ kind: z.literal('now') }),
267
- z.object({ kind: z.literal('valid_until') }),
281
+ // `valid_until` is NOT in the public predicate grammar: the encoder +
282
+ // decoder already throw on it, so accepting it here would let a
283
+ // hand-crafted payload through the schema and only surface a 200+
284
+ // character internal-commentary error at encode time. Drop it - the
285
+ // policy's expiry is carried at the install layer (MandateSpec +
286
+ // validUntilLedger) instead.
268
287
  z.object({
269
288
  kind: z.literal('invocation_count_in_window'),
270
289
  windowSecs: z.number().int().positive(),
@@ -424,20 +443,19 @@ const MandateSpecSchemaForRule = z
424
443
  // install/revoke/get_info schemas live HERE (after the helper) so the const
425
444
  // reference resolves at module init. They are the new tools on top of the
426
445
  // existing four.
427
- /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:128.
446
+ /** Pinned interpreter address (testnet).
428
447
  * Single source for the MCP layer; do not embed elsewhere. */
429
448
  export const PINNED_INTERPRETER_TESTNET_ADDRESS = 'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP';
430
- /** Pinned interpreter address (mainnet). Mirrors DEPLOYMENTS.md:18. Mainnet
449
+ /** Pinned interpreter address (mainnet). Mainnet
431
450
  * has now been deployed (2026-08-04); the mainnet interpreter IS the binary
432
451
  * that was exercised on testnet (same wasm sha256, see
433
452
  * PINNED_INTERPRETER_WASM_SHA256). The address differs because instance
434
- * ids are network-scoped. UNAUDITED at the time of writing - see
435
- * DEPLOYMENTS.md:29-32 for the recorded decision and what is still pending. */
453
+ * ids are network-scoped. UNAUDITED at the time of writing. */
436
454
  export const PINNED_INTERPRETER_MAINNET_ADDRESS = 'CALZAMUPREIRY4TULBEXIK77AUTOEJG63XLCPUWEHHQDOVK6ZVVS7VQ2';
437
- /** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
455
+ /** Pinned interpreter wasm sha256 (hex). */
438
456
  export const PINNED_INTERPRETER_WASM_SHA256 = '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443';
439
457
  /** The grammar version the interpreter enforces (matches SELF_VERSION in
440
- * packages/policy-interpreter/src/version.rs). */
458
+ * contracts/policy-interpreter/src/version.rs). */
441
459
  export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1;
442
460
  /** Default Soroban RPC for the install / revoke / info tools. The recorder
443
461
  * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
@@ -451,7 +469,7 @@ export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org';
451
469
  export const MAINNET_RPC_URL = 'https://mainnet.sorobanrpc.com';
452
470
  /** Pin + RPC lookup for the gate enforcement. The interpreters' wasm sha256
453
471
  * is identical across both networks (the same binary was uploaded both
454
- * places; DEPLOYMENTS.md:25-27), so `PINNED_INTERPRETER_WASM_SHA256` stays
472
+ * places), so `PINNED_INTERPRETER_WASM_SHA256` stays
455
473
  * a single constant - only the addresses and RPCs are network-scoped. */
456
474
  export const PINNED_INTERPRETER_ADDRESS_BY_NETWORK = {
457
475
  testnet: PINNED_INTERPRETER_TESTNET_ADDRESS,
@@ -471,13 +489,26 @@ export const NETWORK_PASSPHRASES = {
471
489
  testnet: 'Test SDF Network ; September 2015',
472
490
  mainnet: 'Public Global Stellar Network ; September 2015',
473
491
  };
492
+ // Stellar strkey shapes (StrKey base32, no checksum byte, version byte
493
+ // 0x30/0x40 prefixed by the decoder, then 32 base32 chars giving a 56-char
494
+ // total). The shared `isStellarAddress` helper accepts BOTH wallet (G...) and
495
+ // contract (C...) addresses; the install schema has to distinguish them
496
+ // because the smart account is a contract (C...) - a wallet address would
497
+ // mean the rule is being installed against the WRONG account kind entirely.
498
+ // `sourceAccount` is the signing wallet (G...).
499
+ const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/;
500
+ const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/;
474
501
  export const InstallPolicyInputSchema = z
475
502
  .object({
476
503
  /** The smart account contract address (C...) that will receive the rule. */
477
- smartAccount: z.string(),
504
+ smartAccount: z
505
+ .string()
506
+ .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
478
507
  /** The signer that authorises the install (G... wallet). Used only for
479
508
  * sequence number + auth nonce simulation; never persisted, never signed. */
480
- sourceAccount: z.string(),
509
+ sourceAccount: z
510
+ .string()
511
+ .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
481
512
  /** Target network for the install. Selects which interpreter pin and
482
513
  * which RPC URL are valid by default. Defaults to `testnet` so the
483
514
  * pre-mainnet callers keep working: they were always pointing at
@@ -516,12 +547,16 @@ export const InstallPolicyInputSchema = z
516
547
  export const RevokePolicyInputSchema = z
517
548
  .object({
518
549
  /** The smart account contract address (C...). */
519
- smartAccount: z.string(),
550
+ smartAccount: z
551
+ .string()
552
+ .regex(STELLAR_CONTRACT_ADDRESS, 'smartAccount must be a Stellar contract address (C...)'),
520
553
  /** The wallet that will sign the removal. The ACCOUNT decides whether it
521
554
  * accepts that signer; this schema does not assert a rule it cannot
522
555
  * verify, since the account's source is not in this repo. Proven on
523
556
  * testnet: the account's deployer can revoke. */
524
- sourceAccount: z.string(),
557
+ sourceAccount: z
558
+ .string()
559
+ .regex(STELLAR_ACCOUNT_ADDRESS, 'sourceAccount must be a Stellar account address (G...)'),
525
560
  /** Target network for the revoke. Same `testnet`-default as install,
526
561
  * so pre-mainnet callers keep working without an explicit flag. */
527
562
  network: NetworkSchema.optional(),
@@ -543,8 +578,15 @@ export const GetInterpreterInfoInputSchema = z.object({
543
578
  /** When true, perform an optional live `grammar_version()` RPC call to
544
579
  * verify the deployed contract matches the pin. */
545
580
  verifyLive: z.boolean().optional(),
546
- /** Optional RPC URL override. */
581
+ /** Optional RPC URL override. When `verifyLive` is true, the URL must
582
+ * equal the pinned RPC for the selected network unless
583
+ * `allowUnpinnedRpcUrl: true` is also set (same opt-in shape as
584
+ * install/revoke - the live grammar_version() answer binds to whichever
585
+ * RPC returned it). */
547
586
  rpcUrl: z.string().url().optional(),
587
+ /** Opt-in to using a non-pinned RPC URL when `verifyLive` is true. Same
588
+ * semantics as install/revoke: the caller accepts the trust shift. */
589
+ allowUnpinnedRpcUrl: z.boolean().optional(),
548
590
  });
549
591
  // ===== Error envelope (canonical) =====
550
592
  //