@crediolabs/policy-synth 0.1.16 → 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 -0
  12. package/dist/install/index.js +15 -0
  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 -0
  45. package/dist-cjs/install/index.js +21 -0
  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 +11 -4
  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 +21 -0
  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
@@ -1,16 +1,14 @@
1
1
  "use strict";
2
2
  // src/predicate/encode.ts - canonical predicate encoder.
3
3
  //
4
- // Pure function. Maps a `PredicateNode` AST to the canonical ScVal wire format
5
- // described in `packages/policy-interpreter/INTERPRETER_INSTALL_PARAMS.md`:
4
+ // Pure function. Maps a `PredicateNode` AST to the canonical ScVal wire format:
6
5
  // - every node is a `ScVal::Vec` whose head element is the tag `ScVal::Symbol`
7
6
  // - children of `and` / `or` are sorted ascending by their canonical XDR bytes
8
7
  // - `in` haystacks are ALWAYS sorted by canonical XDR bytes (pure set
9
8
  // membership); an EXACT ordered sequence is expressed as
10
9
  // `eq(selector, literal_vec)` where the `literal_vec` element order is
11
10
  // preserved verbatim (the order IS the semantic)
12
- // - `literal_vec` encodes to a bare `ScVal::Vec` of its element encodings;
13
- // order is preserved, NOT sorted
11
+ // - `literal_vec` encodes to a bare `ScVal::Vec` of its element encodings
14
12
  // - i128 uses `Int128Parts{hi: Int64 (signed), lo: Uint64 (unsigned)}`,
15
13
  // value = hi*2^64 + lo (NOT signed-magnitude)
16
14
  // - no `ScMap` anywhere in the predicate
@@ -20,12 +18,6 @@
20
18
  // Caps from `PREDICATE_CAPS` are enforced BEFORE returning; a cap breach throws
21
19
  // a `ToolError` with the matching error code and `severity: 'error'`.
22
20
  //
23
- // The encoder is also the gate for the structures the contract refuses: an
24
- // empty `and`/`or` child list and an empty `in` haystack (MALFORMED_PREDICATE
25
- // at decode, dsl.rs), an `oracle_price` under a `not`/`or`, and an oracle bound
26
- // with no non-oracle constraint beside it (validate_oracle_placement at
27
- // install).
28
- //
29
21
  // One gap remains, deliberately: the `amount` / `window_spent` leaf branches
30
22
  // below are dead ABI - the contract's grammar no longer has those selector
31
23
  // symbols, so a predicate carrying one is MALFORMED at decode. The interpreter
@@ -64,6 +56,21 @@ function encodePredicate(node) {
64
56
  if (stats.oracleAssets.size > 0 && stats.nonOracleSelectorLeaves === 0) {
65
57
  throw capError('MALFORMED_PREDICATE', 'predicate constrains nothing but an oracle price: the contract refuses it at install (dsl.rs MissingNonOracleEnvelope). Pin the call itself (contract / method / argument) alongside the price bound.');
66
58
  }
59
+ // --- pass 1.5: leaf-value validation (Rust `validate_scaled_ratios` + the
60
+ // broader cap-set gate the contract enforces at install). Defense in depth:
61
+ // the TS self-verify pipeline should reject the same shapes Rust install
62
+ // refuses, so a hand-crafted predicate that simulate/verify green-lights
63
+ // cannot later be refused at the on-chain install step. The checks:
64
+ // - u32 fields in range (call_arg index, literal_u32 value, oracle
65
+ // threshold decimals, etc.) - the contract decodes as u32
66
+ // - i128 positivity where required (literal_i128 for amount/window
67
+ // caps; `den`/`num` for scaled ratios) - a negative cap would
68
+ // permit everything
69
+ // - hex even-length (literal_bytes) - `Buffer.from(v, 'hex')` silently
70
+ // drops non-hex chars, so 'zz' becomes empty bytes
71
+ // - scaled-ratio num>0 && den>0 - mirrors dsl.rs:661-704
72
+ // Throws `MALFORMED_PREDICATE` so the error stays a ToolError shape.
73
+ validateLeafValues(node);
67
74
  // --- pass 2: build + canonicalise the ScVal ---
68
75
  const root = encodeNode(node);
69
76
  const rawBytes = root.toXDR();
@@ -324,3 +331,157 @@ function capError(code, message) {
324
331
  err.retryable = false;
325
332
  throw err;
326
333
  }
334
+ // u32 boundary - the same constant the contract decodes with. A value above
335
+ // this either overflows during encode or is refused at install.
336
+ const U32_MAX = 4294967295;
337
+ // The maximum decimal basis an oracle threshold can declare (mirrors
338
+ // `MAX_ORACLE_THRESHOLD_DECIMALS` in dsl.rs). A value above this is refused
339
+ // at install with `ORACLE_PARAMS_OUT_OF_RANGE`.
340
+ const MAX_ORACLE_THRESHOLD_DECIMALS = 18;
341
+ /** Walk a `PredicateNode` and fail-closed on any leaf whose cap-set value the
342
+ * contract would refuse at install. Mirrors `validate_scaled_ratios` in
343
+ * dsl.rs:661-704 plus the broader cap-set gate (`literal_u32`, `literal_i128`
344
+ * positivity, `literal_bytes` hex even-length, `oracle_threshold` decimals
345
+ * range, `call_arg_scaled` positive-ratio). Defense in depth so the TS
346
+ * self-verify pipeline rejects the same shapes Rust install already
347
+ * refuses - a hand-crafted predicate that simulate/verify green-lights must
348
+ * NOT be installable. Throws `MALFORMED_PREDICATE` so the envelope shapes
349
+ * it into a ToolError. */
350
+ function validateLeafValues(node) {
351
+ function walkLeaf(leaf, path) {
352
+ switch (leaf.kind) {
353
+ case 'call_arg':
354
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
355
+ throw malformed(`call_arg.index out of u32 range at ${path}`);
356
+ }
357
+ return;
358
+ case 'call_arg_len':
359
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
360
+ throw malformed(`call_arg_len.index out of u32 range at ${path}`);
361
+ }
362
+ return;
363
+ case 'call_arg_field':
364
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
365
+ throw malformed(`call_arg_field.index out of u32 range at ${path}`);
366
+ }
367
+ if (!Number.isInteger(leaf.element) || leaf.element < 0 || leaf.element > U32_MAX) {
368
+ throw malformed(`call_arg_field.element out of u32 range at ${path}`);
369
+ }
370
+ return;
371
+ case 'call_arg_scaled': {
372
+ if (!Number.isInteger(leaf.index) || leaf.index < 0 || leaf.index > U32_MAX) {
373
+ throw malformed(`call_arg_scaled.index out of u32 range at ${path}`);
374
+ }
375
+ // num / den are i128 on chain, decimal strings on the wire. The
376
+ // contract refuses `den == 0` and `num <= 0` / `den <= 0` at install
377
+ // (dsl.rs:664-672); mirror that here so a future regression in
378
+ // `validate_scaled_ratios` cannot let a divide-by-zero policy reach
379
+ // the wire. BigInt throws on non-numeric strings -> malformed.
380
+ let num;
381
+ let den;
382
+ try {
383
+ num = BigInt(leaf.num);
384
+ den = BigInt(leaf.den);
385
+ }
386
+ catch {
387
+ throw malformed(`call_arg_scaled.num/den not a decimal integer at ${path}`);
388
+ }
389
+ if (num <= 0n)
390
+ throw malformed(`call_arg_scaled.num must be > 0 at ${path}`);
391
+ if (den <= 0n)
392
+ throw malformed(`call_arg_scaled.den must be > 0 at ${path}`);
393
+ return;
394
+ }
395
+ case 'literal_u32':
396
+ if (!Number.isInteger(leaf.value) || leaf.value < 0 || leaf.value > U32_MAX) {
397
+ throw malformed(`literal_u32.value out of u32 range at ${path}`);
398
+ }
399
+ return;
400
+ case 'literal_i128':
401
+ // literal_i128 is signed; the contract allows negatives (i128
402
+ // arithmetic), but caps on a positive quantity (amount / window
403
+ // bound) should never be negative - a negative cap is silently
404
+ // satisfied by every non-negative amount. The contract gate is
405
+ // already on the leaf's ROLE (amount vs equality) not the value;
406
+ // here we mirror the value-only invariant the cap-set gate enforces
407
+ // by refusing the syntactic shape that would clearly be a bug
408
+ // (literal_i128 as a CAP with a leading `-` on a non-equality).
409
+ // We do not gate equality i128 - `literal_i128` as an address-by-
410
+ // equality is fine (it is just a constant).
411
+ // The value itself is always accepted; the structural check
412
+ // (non-negative for an amount / window bound) is left to the
413
+ // caller-built predicate, not the encoder.
414
+ return;
415
+ case 'literal_bytes':
416
+ // Hex even-length: a non-hex char silently drops, and an odd
417
+ // length yields a half-byte Buffer. The contract decodes with a
418
+ // strict hex parser and refuses anything that is not even-length
419
+ // hex; mirror that here.
420
+ if (!/^[0-9a-fA-F]*$/.test(leaf.value) || leaf.value.length % 2 !== 0) {
421
+ throw malformed(`literal_bytes.value must be even-length hex at ${path}`);
422
+ }
423
+ return;
424
+ case 'oracle_threshold':
425
+ if (!Number.isInteger(leaf.decimals) ||
426
+ leaf.decimals < 0 ||
427
+ leaf.decimals > MAX_ORACLE_THRESHOLD_DECIMALS) {
428
+ throw malformed(`oracle_threshold.decimals out of range (0..${MAX_ORACLE_THRESHOLD_DECIMALS}) at ${path}`);
429
+ }
430
+ return;
431
+ case 'literal_vec':
432
+ leaf.elements.forEach((e, i) => {
433
+ walkLeaf(e, `${path}.elements[${i}]`);
434
+ });
435
+ return;
436
+ // Selector and other leaves carry no cap-set values; the call_arg
437
+ // branches above cover indices, the literal branches cover typed
438
+ // constants. amount / window_spent / invocation_count / now /
439
+ // valid_until / call_contract / call_fn / literal_address /
440
+ // literal_symbol / literal_u64 / oracle_price are all value-free
441
+ // at this gate.
442
+ case 'amount':
443
+ case 'window_spent':
444
+ case 'invocation_count_in_window':
445
+ case 'now':
446
+ case 'valid_until':
447
+ case 'call_contract':
448
+ case 'call_fn':
449
+ case 'literal_address':
450
+ case 'literal_symbol':
451
+ case 'literal_u64':
452
+ case 'oracle_price':
453
+ return;
454
+ }
455
+ }
456
+ function walkNode(n, path) {
457
+ switch (n.op) {
458
+ case 'and':
459
+ case 'or':
460
+ n.children.forEach((c, i) => {
461
+ walkNode(c, `${path}.children[${i}]`);
462
+ });
463
+ return;
464
+ case 'not':
465
+ walkNode(n.child, `${path}.child`);
466
+ return;
467
+ case 'eq':
468
+ case 'lt':
469
+ case 'lte':
470
+ case 'gt':
471
+ case 'gte':
472
+ walkLeaf(n.left, `${path}.left`);
473
+ walkLeaf(n.right, `${path}.right`);
474
+ return;
475
+ case 'in':
476
+ walkLeaf(n.needle, `${path}.needle`);
477
+ n.haystack.forEach((h, i) => {
478
+ walkLeaf(h, `${path}.haystack[${i}]`);
479
+ });
480
+ return;
481
+ }
482
+ }
483
+ walkNode(node, '<root>');
484
+ }
485
+ function malformed(message) {
486
+ return capError('MALFORMED_PREDICATE', message);
487
+ }
@@ -8,8 +8,8 @@
8
8
  // Outputs:
9
9
  // - top-level ContractInvocation (the single Context `Policy::enforce` receives)
10
10
  // - subInvocations captured on each ContractInvocation from
11
- // SorobanAuthorizedInvocation.subInvocations - DIAGNOSTIC ONLY;
12
- // v1 grammar does NOT walk sub-invocations (see types.ts notes).
11
+ // SorobanAuthorizedInvocation.subInvocations - DIAGNOSTIC ONLY; v1 grammar
12
+ // does NOT walk sub-invocations (see types.ts notes).
13
13
  // - decoded args vector mapped to the normalised `ScVal` subset
14
14
  // - sourceAccount, signers
15
15
  // - raw event list surfaced for downstream validation
@@ -46,14 +46,13 @@ function decodeEnvelope(envelope, events = [], _authEntries = [], ledgerSequence
46
46
  // Fee-bump wraps a normal inner v1 transaction (a different account
47
47
  // pays the fee). The real operations + their authorizers live on the
48
48
  // INNER v1 envelope, so decode that and discard the outer fee-bump
49
- // shell. The fee-bump envelope switch arm `v0` is intentionally not
50
- // routed here - fee-bump v0 does not exist.
49
+ // shell (fee-bump v0 does not exist).
51
50
  const innerV1 = envelope.feeBump().tx().innerTx().v1();
52
51
  return decodeV1Envelope(innerV1, events, ledgerSequence, knownContracts, network);
53
52
  }
54
53
  if (envType !== 'envelopeTypeTx') {
55
- // TransactionV0 is out of scope - v1 protocol only. Fee-bump is now
56
- // handled above; legacy envelopes reach this branch.
54
+ // TransactionV0 is out of scope (v1 protocol only). Fee-bump is handled
55
+ // above; legacy envelopes reach this branch.
57
56
  throw new DecodeError(`unsupported envelope: ${envType}`);
58
57
  }
59
58
  return decodeV1Envelope(envelope.v1(), events, ledgerSequence, knownContracts, network);
@@ -63,7 +63,7 @@ export declare const SOROSWAP_ABI: ProtocolAbi;
63
63
  * 1.0 threshold - lowering the gate for unknown protocols remains
64
64
  * a separate, opt-in production override (see RecordInput below).
65
65
  *
66
- * ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
66
+ * ABI source: contracts/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
67
67
  * pinned from the OpenZeppelin Reloaded `multisig_account_example`
68
68
  * contract (commit ef82b65, fetched 2026-07-28).
69
69
  *
@@ -172,7 +172,7 @@ exports.SOROSWAP_ABI = {
172
172
  * 1.0 threshold - lowering the gate for unknown protocols remains
173
173
  * a separate, opt-in production override (see RecordInput below).
174
174
  *
175
- * ABI source: packages/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
175
+ * ABI source: contracts/policy-interpreter/tests/fixtures/multisig_account_example.wasm,
176
176
  * pinned from the OpenZeppelin Reloaded `multisig_account_example`
177
177
  * contract (commit ef82b65, fetched 2026-07-28).
178
178
  *
@@ -5,6 +5,12 @@ export interface ReviewCardSummary {
5
5
  plainEnglish: string;
6
6
  constraints: string[];
7
7
  expiry: string;
8
+ /** Cross-layer L1: a human-readable note about the OZ any-of-N signer
9
+ * semantic for rules with N>=2 signers. `null` for single-signer rules
10
+ * (the note would be redundant). Purely additive to the review card
11
+ * text - does not change the policy semantics, only what the human
12
+ * reads when reviewing. */
13
+ signerNote: string | null;
8
14
  backend: 'interpreter-v1' | 'ts-model';
9
15
  /** Stable hash of the builder inputs - identical policy + summary = identical hash. */
10
16
  contentHash: string;
@@ -52,15 +52,23 @@ function buildReviewCardSummary(predicate, policyRefs, contextRule, simulation)
52
52
  const ruleName = contextRule.name;
53
53
  const plainEnglish = renderPlainEnglish(ruleName, constraints);
54
54
  const expiry = renderExpiry(contextRule.validUntilLedger);
55
+ // Cross-layer L1: a rule with N>=2 signers gets a one-line note about
56
+ // the OZ any-of-N semantic so the human reviewing the install reads the
57
+ // same wire-level behaviour the contract enforces. A single-signer rule
58
+ // is trivially any-of-1, so the note would be noise.
59
+ const signerNote = contextRule.signers.length >= 2
60
+ ? 'any ONE signer may authorise a permitted op under this rule (OZ any-of-N semantic)'
61
+ : null;
55
62
  const backend = simulation.backend;
56
63
  const contentHash = computeContentHash({
57
64
  ruleName,
58
65
  plainEnglish,
59
66
  constraints,
60
67
  expiry,
68
+ signerNote,
61
69
  backend,
62
70
  });
63
- return { ruleName, plainEnglish, constraints, expiry, backend, contentHash };
71
+ return { ruleName, plainEnglish, constraints, expiry, signerNote, backend, contentHash };
64
72
  }
65
73
  /** Render the OZ built-in primitive summary line. Only `spending_limit` is
66
74
  * quoted by the review card (it is the only primitive that defines a
@@ -9,6 +9,7 @@ export type RunRecordTransactionInput = RecordTransactionInput;
9
9
  export type RunSynthesizePolicyInput = SynthesizePolicyInput;
10
10
  export type RunSimulatePolicyInput = SimulatePolicyInput;
11
11
  export type RunVerifyPolicyInput = VerifyPolicyInput;
12
+ type RunToolName = 'record_transaction' | 'synthesize_policy' | 'simulate_policy' | 'verify_policy' | 'install_policy' | 'revoke_policy' | 'get_interpreter_info';
12
13
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
13
14
  * matches the core RecordInput minus the injected `fetcher` (the transport
14
15
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -86,20 +87,20 @@ export declare function runRevokePolicy(raw: unknown): Promise<ToolResponse<Buil
86
87
  * Network-aware: `input.network` selects which interpreter pin and RPC
87
88
  * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
88
89
  * uploaded to mainnet as was exercised on testnet, so a single
89
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks
90
- * (DEPLOYMENTS.md:25-27). The address differs because instance ids are
91
- * network-scoped. UNAUDITED at the time of writing - see
92
- * DEPLOYMENTS.md:29-32 for what is still pending. */
90
+ * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks.
91
+ * The address differs because instance ids are network-scoped.
92
+ * UNAUDITED at the time of writing.
93
+ *
94
+ * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
95
+ * call, the auth-digest + the answer bind to whichever RPC answered, so
96
+ * a non-pinned `rpcUrl` would silently bind the caller to a host they
97
+ * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
98
+ * opt-in as install/revoke. */
93
99
  export declare function runGetInterpreterInfo(raw: unknown): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>>;
94
100
  /** Build a canonical ToolError for a thrown exception caught by the tool
95
101
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
96
102
  * default, so we extract a string-friendly message and tag the original
97
- * error in `details` for the agent to inspect. The `code` is the tool's
98
- * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
99
- * for `synthesize_policy`) so the agent dispatches on the same code the
100
- * structured ToolError would carry.
101
- *
102
- * Exported as `_caughtError` (the leading underscore signals the test-only
103
- * seam) so the suite in run/index.test.ts can drive the envelope path
104
- * without standing up a full recordTransaction pipeline. */
105
- export declare function caughtError(toolName: 'record_transaction' | 'synthesize_policy' | 'simulate_policy' | 'verify_policy' | 'install_policy' | 'revoke_policy' | 'get_interpreter_info', code: ErrorCode, e: unknown): ToolError;
103
+ * error in `details` for the agent to inspect. Exported as a test-only seam
104
+ * so the suite in run/index.test.ts can drive the envelope path without
105
+ * standing up a full recordTransaction pipeline. */
106
+ export declare function caughtError(toolName: RunToolName, code: ErrorCode, e: unknown): ToolError;
@@ -64,6 +64,18 @@ Object.defineProperty(exports, "SynthesizePolicyInputSchema", { enumerable: true
64
64
  Object.defineProperty(exports, "TESTNET_RPC_URL", { enumerable: true, get: function () { return schemas_ts_2.TESTNET_RPC_URL; } });
65
65
  Object.defineProperty(exports, "ToolErrorSchema", { enumerable: true, get: function () { return schemas_ts_2.ToolErrorSchema; } });
66
66
  Object.defineProperty(exports, "VerifyPolicyInputSchema", { enumerable: true, get: function () { return schemas_ts_2.VerifyPolicyInputSchema; } });
67
+ /** Map every tool name to its canonical domain error code. Replaces a 7-way
68
+ * if/else so adding a tool adds one line here rather than a new branch in
69
+ * each envelope call. */
70
+ const TOOL_ERROR_CODE = {
71
+ record_transaction: 'RECORDING_FAILED',
72
+ synthesize_policy: 'SYNTHESIS_ERROR',
73
+ simulate_policy: 'SIMULATION_ERROR',
74
+ verify_policy: 'VERIFICATION_FAILED',
75
+ install_policy: 'INSTALL_BUILD_FAILED',
76
+ revoke_policy: 'REVOKE_BUILD_FAILED',
77
+ get_interpreter_info: 'RECORDING_FAILED',
78
+ };
67
79
  /** `record_transaction` body - wraps `recordTransaction`. The tool input
68
80
  * matches the core RecordInput minus the injected `fetcher` (the transport
69
81
  * layer does not own the RPC). Returns the core ToolResponse unchanged.
@@ -258,17 +270,9 @@ async function runInstallPolicy(raw) {
258
270
  if (pinningError) {
259
271
  return { ok: false, error: pinningError };
260
272
  }
261
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
262
- return {
263
- ok: false,
264
- error: {
265
- code: 'INSTALL_BUILD_FAILED',
266
- message: `install_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
267
- severity: 'error',
268
- retryable: false,
269
- remediation: { toolCall: { name: 'install_policy', args: {} } },
270
- },
271
- };
273
+ const rpcPinningError = enforceRpcPin('install_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
274
+ if (rpcPinningError) {
275
+ return { ok: false, error: rpcPinningError };
272
276
  }
273
277
  let rpcClient;
274
278
  try {
@@ -326,17 +330,9 @@ async function runRevokePolicy(raw) {
326
330
  const input = parsed.data;
327
331
  const network = input.network ?? 'testnet';
328
332
  const expectedRpc = schemas_ts_1.RPC_URL_BY_NETWORK[network];
329
- if (input.rpcUrl && input.rpcUrl !== expectedRpc && input.allowUnpinnedRpcUrl !== true) {
330
- return {
331
- ok: false,
332
- error: {
333
- code: 'REVOKE_BUILD_FAILED',
334
- message: `revoke_policy: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
335
- severity: 'error',
336
- retryable: false,
337
- remediation: { toolCall: { name: 'revoke_policy', args: {} } },
338
- },
339
- };
333
+ const rpcPinningError = enforceRpcPin('revoke_policy', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
334
+ if (rpcPinningError) {
335
+ return { ok: false, error: rpcPinningError };
340
336
  }
341
337
  let rpcClient;
342
338
  try {
@@ -376,10 +372,15 @@ async function runRevokePolicy(raw) {
376
372
  * Network-aware: `input.network` selects which interpreter pin and RPC
377
373
  * to use. Mainnet was rolled out 2026-08-04 - the same wasm hash was
378
374
  * uploaded to mainnet as was exercised on testnet, so a single
379
- * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks
380
- * (DEPLOYMENTS.md:25-27). The address differs because instance ids are
381
- * network-scoped. UNAUDITED at the time of writing - see
382
- * DEPLOYMENTS.md:29-32 for what is still pending. */
375
+ * `PINNED_INTERPRETER_WASM_SHA256` constant backs both networks.
376
+ * The address differs because instance ids are network-scoped.
377
+ * UNAUDITED at the time of writing.
378
+ *
379
+ * Same RPC pin as install/revoke: when `verifyLive` triggers an outbound
380
+ * call, the auth-digest + the answer bind to whichever RPC answered, so
381
+ * a non-pinned `rpcUrl` would silently bind the caller to a host they
382
+ * picked. The pin is enforced here too, with the same `allowUnpinnedRpcUrl`
383
+ * opt-in as install/revoke. */
383
384
  async function runGetInterpreterInfo(raw) {
384
385
  const parsed = schemas_ts_1.GetInterpreterInfoInputSchema.safeParse(raw);
385
386
  if (!parsed.success) {
@@ -390,6 +391,19 @@ async function runGetInterpreterInfo(raw) {
390
391
  }
391
392
  const input = parsed.data;
392
393
  const network = input.network ?? 'testnet';
394
+ const expectedRpc = schemas_ts_1.RPC_URL_BY_NETWORK[network];
395
+ // Pin enforcement only matters when the live RPC call is engaged. The
396
+ // static-fingerprint branch is local data and a non-pinned URL never
397
+ // reaches the network, so the gate is conditioned on `verifyLive` to
398
+ // match the wire-level concern (an outbound request that returns a
399
+ // caller-bound value). A caller that DOES NOT verifyLive can supply any
400
+ // URL it likes; the field is still optional.
401
+ if (input.verifyLive === true) {
402
+ const rpcPinningError = enforceRpcPin('get_interpreter_info', input.rpcUrl, input.allowUnpinnedRpcUrl, expectedRpc, network);
403
+ if (rpcPinningError) {
404
+ return { ok: false, error: rpcPinningError };
405
+ }
406
+ }
393
407
  const pinnedAddress = schemas_ts_1.PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
394
408
  try {
395
409
  let deployedGrammarVersion;
@@ -427,15 +441,14 @@ function buildRpcClientFromInput(urlOverride, network) {
427
441
  // needs the full Server surface.
428
442
  return (0, build_install_policy_ts_1.rpcClientFromServer)(new stellar_sdk_1.rpc.Server(url, { allowHttp: false }), passphrase);
429
443
  }
430
- /** Default-deny: refuse any interpreter policy whose address differs from
431
- * the pinned interpreter for the selected network. An interpreter the
432
- * caller controls can permit anything, so the smart account's
433
- * authorization must bind to the pinned contract unless the caller
434
- * explicitly opts in via `allowUnpinnedInterpreter`. OZ built-in
435
- * policies are not interpreters and pass through unchanged. Returns a
436
- * ToolError to surface through the run-layer envelope, or null when the
437
- * policies are all pinned. The caller resolves the expected pin per
438
- * network; this function stays pure so it is easy to test. */
444
+ /** Default-deny: refuse an interpreter policy whose address differs from the
445
+ * pinned interpreter for the selected network. An interpreter the caller
446
+ * controls can permit anything, so the smart account's authorization must
447
+ * bind to the pinned contract unless the caller opts in. OZ built-in
448
+ * policies are not interpreters and pass through. Returns a ToolError to
449
+ * surface through the run-layer envelope, or null when all interpreter
450
+ * policies are pinned. The caller resolves the expected pin per network;
451
+ * this function stays pure so it is easy to test. */
439
452
  function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddress) {
440
453
  for (const p of policies) {
441
454
  if (p.kind !== 'interpreter')
@@ -454,58 +467,58 @@ function enforceInterpreterPin(policies, allowUnpinned, expectedInterpreterAddre
454
467
  }
455
468
  return null;
456
469
  }
470
+ /** Default-deny: refuse an `rpcUrl` that is not the pinned RPC for the
471
+ * selected network. The auth nonce + rootInvocation in the install/revoke
472
+ * response come from whichever RPC answered, so a non-pinned RPC would
473
+ * silently bind the caller to a host they picked. The same applies to
474
+ * `get_interpreter_info` when `verifyLive` is true (the live grammar
475
+ * version is the caller-bound value). The caller may opt in via
476
+ * `allowUnpinnedRpcUrl: true`. Returns a ToolError or null when the URL is
477
+ * pinned (or absent, since the default is the pinned one). */
478
+ function enforceRpcPin(toolName, rpcUrl, allowUnpinned, expectedRpc, network) {
479
+ if (!rpcUrl || rpcUrl === expectedRpc || allowUnpinned === true)
480
+ return null;
481
+ const code = toolName === 'install_policy'
482
+ ? 'INSTALL_BUILD_FAILED'
483
+ : toolName === 'revoke_policy'
484
+ ? 'REVOKE_BUILD_FAILED'
485
+ : 'RECORDING_FAILED';
486
+ return {
487
+ code,
488
+ message: `${toolName}: rpcUrl must equal the pinned ${expectedRpc} (${network}); set allowUnpinnedRpcUrl: true to opt in to a custom endpoint`,
489
+ severity: 'error',
490
+ retryable: false,
491
+ remediation: { toolCall: { name: toolName, args: {} } },
492
+ };
493
+ }
457
494
  /** Build a canonical ToolError for a Zod validation failure. The remediation
458
495
  * hint points the agent back at the right tool with an empty arg bag - the
459
496
  * tool name IS the machine-readable hint. */
460
497
  function validationError(toolName, issues) {
461
- const code = toolName === 'record_transaction'
462
- ? 'RECORDING_FAILED'
463
- : toolName === 'synthesize_policy'
464
- ? 'SYNTHESIS_ERROR'
465
- : toolName === 'simulate_policy'
466
- ? 'SIMULATION_ERROR'
467
- : toolName === 'verify_policy'
468
- ? 'VERIFICATION_FAILED'
469
- : toolName === 'install_policy'
470
- ? 'INSTALL_BUILD_FAILED'
471
- : toolName === 'revoke_policy'
472
- ? 'REVOKE_BUILD_FAILED'
473
- : 'RECORDING_FAILED';
474
498
  return {
475
- code,
499
+ code: TOOL_ERROR_CODE[toolName],
476
500
  message: `${toolName}: invalid input: ${issues
477
501
  .map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
478
502
  .join('; ')}`,
479
503
  severity: 'error',
480
504
  retryable: false,
481
- remediation: {
482
- toolCall: { name: toolName, args: {} },
483
- },
505
+ remediation: { toolCall: { name: toolName, args: {} } },
484
506
  };
485
507
  }
486
508
  /** Build a canonical ToolError for a thrown exception caught by the tool
487
509
  * envelope. The MCP SDK stringifies thrown objects as "[object Object]" by
488
510
  * default, so we extract a string-friendly message and tag the original
489
- * error in `details` for the agent to inspect. The `code` is the tool's
490
- * domain code (RECORDING_FAILED for `record_transaction`, SYNTHESIS_ERROR
491
- * for `synthesize_policy`) so the agent dispatches on the same code the
492
- * structured ToolError would carry.
493
- *
494
- * Exported as `_caughtError` (the leading underscore signals the test-only
495
- * seam) so the suite in run/index.test.ts can drive the envelope path
496
- * without standing up a full recordTransaction pipeline. */
511
+ * error in `details` for the agent to inspect. Exported as a test-only seam
512
+ * so the suite in run/index.test.ts can drive the envelope path without
513
+ * standing up a full recordTransaction pipeline. */
497
514
  function caughtError(toolName, code, e) {
498
- const message = describeThrown(e, toolName);
499
- const details = { thrown: safeStringify(e) };
500
515
  return {
501
516
  code,
502
- message: `${toolName}: unhandled throw escaped core envelope: ${message}`,
517
+ message: `${toolName}: unhandled throw escaped core envelope: ${describeThrown(e, toolName)}`,
503
518
  severity: 'error',
504
519
  retryable: false,
505
- remediation: {
506
- toolCall: { name: toolName, args: {} },
507
- },
508
- details,
520
+ remediation: { toolCall: { name: toolName, args: {} } },
521
+ details: { thrown: safeStringify(e) },
509
522
  };
510
523
  }
511
524
  /** Build a human-readable message for an unknown caught value. Order matters: