@crediolabs/policy-synth 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/install/build-add-context-rule.js +48 -16
  2. package/dist/install/build-install-policy.d.ts +21 -0
  3. package/dist/install/build-install-policy.js +41 -3
  4. package/dist/predicate/encode.d.ts +12 -0
  5. package/dist/predicate/encode.js +5 -1
  6. package/dist/run/index.d.ts +14 -5
  7. package/dist/run/index.js +263 -14
  8. package/dist/run/schemas.d.ts +2277 -474
  9. package/dist/run/schemas.js +173 -14
  10. package/dist/synth/lower.d.ts +6 -2
  11. package/dist/synth/lower.js +21 -8
  12. package/dist/synth/synthesize-from-recording.js +1 -1
  13. package/dist/types.d.ts +18 -1
  14. package/dist-cjs/install/build-add-context-rule.js +48 -16
  15. package/dist-cjs/install/build-install-policy.d.ts +21 -0
  16. package/dist-cjs/install/build-install-policy.js +42 -3
  17. package/dist-cjs/predicate/encode.d.ts +12 -0
  18. package/dist-cjs/predicate/encode.js +5 -0
  19. package/dist-cjs/run/index.d.ts +14 -5
  20. package/dist-cjs/run/index.js +263 -13
  21. package/dist-cjs/run/schemas.d.ts +2277 -474
  22. package/dist-cjs/run/schemas.js +174 -15
  23. package/dist-cjs/synth/lower.d.ts +6 -2
  24. package/dist-cjs/synth/lower.js +21 -8
  25. package/dist-cjs/synth/synthesize-from-recording.js +1 -1
  26. package/dist-cjs/types.d.ts +18 -1
  27. package/package.json +1 -1
  28. package/src/install/build-add-context-rule.ts +65 -21
  29. package/src/install/build-install-policy.ts +65 -10
  30. package/src/predicate/encode.ts +5 -1
  31. package/src/run/index.ts +280 -23
  32. package/src/run/schemas.ts +201 -31
  33. package/src/synth/lower.ts +22 -8
  34. package/src/synth/synthesize-from-recording.ts +1 -1
  35. package/src/types.ts +25 -6
@@ -48,6 +48,7 @@
48
48
  // the account refuses with `Error(Auth, InvalidAction)`.
49
49
  import { createHash } from 'node:crypto';
50
50
  import { Address, xdr } from '@stellar/stellar-sdk';
51
+ import { scvI128FromDecimal } from "../predicate/encode.js";
51
52
  import { GRAMMAR_VERSION, OZ_LIMITS, } from "../types.js";
52
53
  export const DEFAULT_GRAMMAR_VERSION = GRAMMAR_VERSION;
53
54
  /** The verb `add_context_rule` takes on the wire. */
@@ -121,27 +122,58 @@ function encodeSigner(s) {
121
122
  function encodePoliciesMap(args) {
122
123
  const entries = [];
123
124
  for (const ref of args.policies) {
124
- // Refuse anything that is not an interpreter policy rather than skipping
125
- // it. This loop used to `if (kind === 'interpreter')` and drop everything
126
- // else in silence, so a caller attaching an OZ `spending_limit` beside the
127
- // interpreter got a rule WITHOUT the cap and no indication of it - the
128
- // policy the user asked for was simply absent from the install they signed.
129
- // `PolicyRef` has one shape today, so TypeScript narrows this branch to
130
- // `never`; the guard is for callers reaching the built JS untyped, and for
131
- // the next kind added to `PolicyRef` without a case here. Failing loudly is
132
- // the only safe direction: a dropped policy is a missing restriction.
133
- if (ref.kind !== 'interpreter') {
134
- const kind = JSON.stringify(ref.kind ?? null);
135
- throw limitError('INSTALL_BUILD_FAILED', `policy kind ${kind} is not supported; this builder attaches interpreter policies only. Attach an OpenZeppelin built-in through the account layer instead - dropping it here would install a rule missing the restriction you asked for.`);
125
+ // Each policy carries its OWN params. This loop once applied one set of
126
+ // install params to every entry, which is why nothing but the interpreter
127
+ // could be expressed; before that it dropped other kinds in silence, so a
128
+ // caller attaching a cap received a rule without it and no indication.
129
+ // Both directions were wrong. An unknown kind still fails loudly below - a
130
+ // dropped policy is a missing restriction.
131
+ switch (ref.kind) {
132
+ case 'interpreter':
133
+ entries.push(new xdr.ScMapEntry({
134
+ key: Address.fromString(ref.interpreterAddress).toScVal(),
135
+ val: encodePolicyInstallParams(args),
136
+ }));
137
+ break;
138
+ case 'spending_limit':
139
+ entries.push(new xdr.ScMapEntry({
140
+ key: Address.fromString(ref.policyAddress).toScVal(),
141
+ val: encodeSpendingLimitParams(ref),
142
+ }));
143
+ break;
144
+ default: {
145
+ const kind = JSON.stringify(ref.kind ?? null);
146
+ throw limitError('INSTALL_BUILD_FAILED', `policy kind ${kind} is not supported; dropping it here would install a rule missing the restriction you asked for`);
147
+ }
136
148
  }
137
- entries.push(new xdr.ScMapEntry({
138
- key: Address.fromString(ref.interpreterAddress).toScVal(),
139
- val: encodePolicyInstallParams(args),
140
- }));
141
149
  }
142
150
  entries.sort(sortByScValSymbolString);
143
151
  return xdr.ScVal.scvMap(entries);
144
152
  }
153
+ /** OpenZeppelin `spending_limit`'s install params: `{ period_ledgers: u32,
154
+ * spending_limit: i128 }`, emitted in symbol-string order. Validated here
155
+ * rather than left to the chain, because a rolling cap that fails at submit
156
+ * has already cost the caller a signature. */
157
+ function encodeSpendingLimitParams(ref) {
158
+ if (!Number.isInteger(ref.periodLedgers) || ref.periodLedgers <= 0) {
159
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit periodLedgers must be a positive integer - it counts LEDGERS, not seconds; got: ${ref.periodLedgers}`);
160
+ }
161
+ if (!/^[0-9]+$/.test(ref.spendingLimit) || BigInt(ref.spendingLimit) <= 0n) {
162
+ throw limitError('INSTALL_BUILD_FAILED', `spending_limit must be a positive integer in the token's smallest unit; got: ${ref.spendingLimit}`);
163
+ }
164
+ const entries = [
165
+ new xdr.ScMapEntry({
166
+ key: xdr.ScVal.scvSymbol('period_ledgers'),
167
+ val: xdr.ScVal.scvU32(ref.periodLedgers),
168
+ }),
169
+ new xdr.ScMapEntry({
170
+ key: xdr.ScVal.scvSymbol('spending_limit'),
171
+ val: scvI128FromDecimal(ref.spendingLimit),
172
+ }),
173
+ ];
174
+ entries.sort((a, b) => sortBySymbolString(a.key(), b.key()));
175
+ return xdr.ScVal.scvMap(entries);
176
+ }
145
177
  function encodePolicyInstallParams(args) {
146
178
  const predicate = Buffer.from(args.encodedPredicate, 'base64');
147
179
  const computedHash = createHash('sha256').update(predicate).digest('hex');
@@ -93,6 +93,13 @@ export interface InstallCallDescribes {
93
93
  installNonce: number;
94
94
  predicateHash: string;
95
95
  predicateSha256OfEmbeddedBytes: string;
96
+ } | {
97
+ /** An OpenZeppelin built-in bounding the SUM across calls, which the
98
+ * predicate cannot: the interpreter sees one call and keeps no state. */
99
+ kind: 'spending_limit';
100
+ address: string;
101
+ periodLedgers: number;
102
+ spendingLimit: string;
96
103
  }>;
97
104
  /** The install nonce, decoded from the interpreter policy's
98
105
  * `install_nonce` field. Echoed at the top level for reviewer convenience;
@@ -172,3 +179,17 @@ export interface BuildRevokePolicyResult {
172
179
  authValidUntilLedger: number;
173
180
  rootInvocationXdr: string;
174
181
  }
182
+ /** The actionable half of a failed simulation, with the transport half left out.
183
+ *
184
+ * `sim.error` names both why the chain refused the call and which host was
185
+ * asked, and the second half must not reach a caller. So we return only
186
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
187
+ * are what tells an operator whether the source account lacks authority, a
188
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
189
+ * failed" names nothing a caller can act on.
190
+ *
191
+ * Returns "" when the error carries no such form, so the caller keeps its short
192
+ * stable message rather than gaining an empty parenthesis. */
193
+ export declare function simulationReason(sim: {
194
+ error?: string;
195
+ }): string;
@@ -131,6 +131,21 @@ export async function buildRevokePolicyXdr(args) {
131
131
  /** ~25 minutes at 5s/ledger. */
132
132
  const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
133
133
  // ---- internals ----
134
+ /** The actionable half of a failed simulation, with the transport half left out.
135
+ *
136
+ * `sim.error` names both why the chain refused the call and which host was
137
+ * asked, and the second half must not reach a caller. So we return only
138
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
139
+ * are what tells an operator whether the source account lacks authority, a
140
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
141
+ * failed" names nothing a caller can act on.
142
+ *
143
+ * Returns "" when the error carries no such form, so the caller keeps its short
144
+ * stable message rather than gaining an empty parenthesis. */
145
+ export function simulationReason(sim) {
146
+ const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])];
147
+ return codes.length > 0 ? ` (${codes.join(', ')})` : '';
148
+ }
134
149
  /** Record a bare call to the smart account, attach the deploy-time admin rule's
135
150
  * auth entries, and re-simulate to assemble the footprint.
136
151
  *
@@ -160,8 +175,10 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
160
175
  // Short, stable reason. The full `simulateTransaction` error (which
161
176
  // carries host + URL detail) stays in the SDK's own logs - never
162
177
  // reflected back into a user-facing message where it would
163
- // reconnoitre the RPC.
164
- throw new Error(`${errorPrefix}: simulateTransaction failed`);
178
+ // reconnoitre the RPC. `simulationReason` re-adds only the chain's own
179
+ // error codes, which say why the call was refused without saying where
180
+ // the RPC lives.
181
+ throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`);
165
182
  }
166
183
  const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
167
184
  Address.fromScAddress(entry.credentials().address().address()).toString() ===
@@ -180,7 +197,7 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
180
197
  const txWithAuth = buildTx(makeOperation(authEntries));
181
198
  const enforcing = await args.rpc.simulateTransaction(txWithAuth);
182
199
  if (rpc.Api.isSimulationError(enforcing)) {
183
- throw new Error(`${errorPrefix}: auth simulateTransaction failed`);
200
+ throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`);
184
201
  }
185
202
  return {
186
203
  finalTx: rpc.assembleTransaction(txWithAuth, enforcing).build(),
@@ -354,6 +371,27 @@ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
354
371
  observedInstallNonce = installNonce;
355
372
  continue;
356
373
  }
374
+ // OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
375
+ if (fields.has('period_ledgers') || fields.has('spending_limit')) {
376
+ const periodScv = fields.get('period_ledgers');
377
+ if (periodScv?.switch().name !== 'scvU32') {
378
+ throw new Error(`install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`);
379
+ }
380
+ const limitScv = fields.get('spending_limit');
381
+ if (limitScv?.switch().name !== 'scvI128') {
382
+ throw new Error(`install_policy: spending_limit policy ${address} is missing an i128 spending_limit`);
383
+ }
384
+ const parts = limitScv.i128();
385
+ const spendingLimit = ((BigInt(parts.hi().toString()) << 64n) +
386
+ BigInt(parts.lo().toString())).toString();
387
+ policies.push({
388
+ kind: 'spending_limit',
389
+ address,
390
+ periodLedgers: periodScv.u32(),
391
+ spendingLimit,
392
+ });
393
+ continue;
394
+ }
357
395
  throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
358
396
  }
359
397
  // `observedInstallNonce` is the nonce baked into whichever interpreter
@@ -1,3 +1,4 @@
1
+ import { xdr } from '@stellar/stellar-sdk';
1
2
  import { type PredicateNode } from '../types.ts';
2
3
  export interface EncodedPredicate {
3
4
  /** base64 of the canonical ScVal XDR of the predicate root. */
@@ -8,3 +9,14 @@ export interface EncodedPredicate {
8
9
  /** Encode a `PredicateNode` to the canonical ScVal wire format and hash it.
9
10
  * Pure function: same input -> byte-identical output every run. */
10
11
  export declare function encodePredicate(node: PredicateNode): EncodedPredicate;
12
+ /** Build `ScVal::I128(Int128Parts{hi, lo})` from a signed decimal string.
13
+ * `Int128Parts` encodes the value as `(hi << 64) + lo` with `hi` a SIGNED
14
+ * 64-bit int and `lo` an UNSIGNED 64-bit int (this is NOT signed-magnitude).
15
+ * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
16
+ * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
17
+ * bigint/string/number. */
18
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
19
+ * guard on the high word. Exported so the install builder encodes an
20
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
21
+ * implementation is how a value above 2^64 silently loses its high word. */
22
+ export declare function scvI128FromDecimal(decimal: string): xdr.ScVal;
@@ -231,7 +231,11 @@ function scvAddressFromStrkey(strkey) {
231
231
  * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
232
232
  * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
233
233
  * bigint/string/number. */
234
- function scvI128FromDecimal(decimal) {
234
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
235
+ * guard on the high word. Exported so the install builder encodes an
236
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
237
+ * implementation is how a value above 2^64 silently loses its high word. */
238
+ export function scvI128FromDecimal(decimal) {
235
239
  const v = BigInt(decimal);
236
240
  const hi = v >> 64n;
237
241
  const lo = v & UINT64_MAX;
@@ -2,7 +2,7 @@ import { type ErrorCode, type PredicateNode, type ProposedPolicy, type RecordedT
2
2
  import { type AuthorityOverlap } from '../install/authority-overlap.ts';
3
3
  import { type BuildInstallPolicyResult, type BuildRevokePolicyResult } from '../install/build-install-policy.ts';
4
4
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts';
5
- import { type RecordTransactionInput, type SimulatePolicyInput, type SynthesizePolicyInput, type VerifyPolicyInput } from './schemas.ts';
5
+ import { type InstallPolicyInput, type RecordTransactionInput, type SimulatePolicyInput, type SynthesizePolicyInput, type VerifyPolicyInput } from './schemas.ts';
6
6
  export type { DeclarePolicyInput, GetInterpreterInfoInput, InstallPolicyInput, OzBuiltinPolicy, RecordTransactionInput, RevokePolicyInput, SimulatePolicyInput, SynthesizePolicyInput, VerifyPolicyInput, } from './schemas.ts';
7
7
  export { ComposeUserResponsesSchema, DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, InterpreterOptionsSchema, MAINNET_RPC_URL, NetworkSchema, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_MAINNET_ADDRESS, PINNED_INTERPRETER_TESTNET_ADDRESS, PINNED_INTERPRETER_WASM_SHA256, PINNED_OZ_POLICY_ADDRESS_BY_NETWORK, PINNED_OZ_POLICY_WASM_SHA256, PredicateLeafSchema, PredicateNodeSchema, RecordedTransactionSchema, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SynthesizePolicyInputSchema, TESTNET_RPC_URL, ToolErrorSchema, } from './schemas.ts';
8
8
  export type RunRecordTransactionInput = RecordTransactionInput;
@@ -51,13 +51,22 @@ export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<Bui
51
51
  * policy payload, so the interpreter pin is not re-checked here.
52
52
  * Pin selection follows `input.network` (defaults to `testnet`). */
53
53
  export declare function runRevokePolicy(raw: unknown): Promise<ToolResponse<BuildRevokePolicyResult>>;
54
+ /** Scope a rule to whatever contract its predicate pins.
55
+ *
56
+ * Taking this from the predicate rather than from a separate argument means
57
+ * the rule's scope cannot drift from what the predicate actually checks. A
58
+ * predicate that pins no contract yields the default (account-wide) type,
59
+ * which is what an unpinned predicate means. Only the top level is walked:
60
+ * a contract pin nested under an `or` does not scope the rule, because the
61
+ * other branch would not be covered by it. */
62
+ export declare function contextTypeForPredicate(predicate: PredicateNode): NonNullable<InstallPolicyInput['rule']>['contextRuleType'];
54
63
  /** `simulate_policy` body - evaluate a predicate against one recorded call.
55
64
  *
56
65
  * The evaluator is a second implementation of the on-chain semantics, and the
57
66
  * conformance harness asserts it agrees with the Rust interpreter case for
58
67
  * case. A verdict here is therefore a claim about what the contract would do,
59
68
  * not a guess. */
60
- export declare function runSimulatePolicy(raw: unknown): ToolResponse<{
69
+ export declare function runSimulatePolicy(raw: unknown): Promise<ToolResponse<{
61
70
  permitted: boolean;
62
71
  reason: string | null;
63
72
  call: {
@@ -65,7 +74,7 @@ export declare function runSimulatePolicy(raw: unknown): ToolResponse<{
65
74
  fn: string;
66
75
  argCount: number;
67
76
  };
68
- }>;
77
+ }>>;
69
78
  /** `declare_policy` body - the DECLARATIVE front-end.
70
79
  *
71
80
  * `synthesize_policy` infers a predicate from a transaction that happened;
@@ -92,7 +101,7 @@ export declare function runDeclarePolicy(raw: unknown): ToolResponse<{
92
101
  * very transaction it was synthesised from. A deny case that permits means it
93
102
  * is too LOOSE: some mutation of that transaction still gets through. `ok` is
94
103
  * true only when neither holds. */
95
- export declare function runVerifyPolicy(raw: unknown): ToolResponse<{
104
+ export declare function runVerifyPolicy(raw: unknown): Promise<ToolResponse<{
96
105
  ok: boolean;
97
106
  permit: {
98
107
  permitted: boolean;
@@ -104,6 +113,6 @@ export declare function runVerifyPolicy(raw: unknown): ToolResponse<{
104
113
  reason: string | null;
105
114
  }>;
106
115
  dimensionsCovered: number;
107
- }>;
116
+ }>>;
108
117
  export declare function runGetInterpreterInfo(raw: unknown): Promise<ToolResponse<ReturnType<typeof getInterpreterInfo>>>;
109
118
  export declare function caughtError(toolName: RunToolName, code: ErrorCode, e: unknown): ToolError;
package/dist/run/index.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // drive the CLI (which calls into the same core directly without MCP).
19
19
  import { createHash } from 'node:crypto';
20
20
  import { rpc } from '@stellar/stellar-sdk';
21
+ import { PLACEHOLDER_INTERPRETER_ADDRESS } from "../adapters/interpreter/adapter.js";
21
22
  import { declarePredicate, encodePredicate, recordTransaction, synthesizeFromRecording, } from "../index.js";
22
23
  import { findAuthorityOverlaps, } from "../install/authority-overlap.js";
23
24
  import { buildInstallPolicyXdr, buildRevokePolicyXdr, rpcClientFromServer, } from "../install/build-install-policy.js";
@@ -25,7 +26,7 @@ import { getInterpreterInfo } from "../install/get-interpreter-info.js";
25
26
  import { accountRuleReaderFromServer, collectObservedRules } from "../install/read-account-rules.js";
26
27
  import { decodePredicate } from "../predicate/decode.js";
27
28
  import { evaluate, generateCases } from "../simulate/index.js";
28
- import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
29
+ import { DeclarePolicyInputSchema, GetInterpreterInfoInputSchema, InstallPolicyInputSchema, NETWORK_PASSPHRASES, PINNED_INTERPRETER_ADDRESS_BY_NETWORK, PINNED_INTERPRETER_GRAMMAR_VERSION, PINNED_INTERPRETER_WASM_SHA256, PINNED_OZ_POLICY_ADDRESS_BY_NETWORK, RecordTransactionInputSchema, RevokePolicyInputSchema, RPC_URL_BY_NETWORK, SimulatePolicyInputSchema, SynthesizePolicyInputSchema, VerifyPolicyInputSchema, } from "./schemas.js";
29
30
  // Re-export the underlying Zod schemas so the MCP package (and any other
30
31
  // downstream consumer) can import the canonical input shapes from the same
31
32
  // module that owns the tool-body glue. The strict schemas are the source of
@@ -95,7 +96,24 @@ export async function runSynthesizePolicy(raw) {
95
96
  }
96
97
  const input = parsed.data;
97
98
  try {
98
- const recorded = input.recordedTx;
99
+ // `hash` is the agent-friendly alternative to `recordedTx`: re-record here
100
+ // rather than make the caller retype a recording it cannot copy faithfully.
101
+ // A recording failure is returned as-is, so the caller sees why the hash was
102
+ // refused instead of a synthesis error about a payload it never sent.
103
+ let recorded;
104
+ if (input.recordedTx === undefined) {
105
+ const rerecorded = await runRecordTransaction({
106
+ hash: input.transactionHash,
107
+ network: input.network,
108
+ });
109
+ if (!rerecorded.ok) {
110
+ return { ok: false, error: rerecorded.error };
111
+ }
112
+ recorded = rerecorded.data;
113
+ }
114
+ else {
115
+ recorded = input.recordedTx;
116
+ }
99
117
  return await synthesizeFromRecording(recorded, {
100
118
  network: input.network,
101
119
  ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
@@ -117,10 +135,143 @@ export async function runInstallPolicy(raw) {
117
135
  }
118
136
  const input = parsed.data;
119
137
  const network = input.network ?? 'testnet';
138
+ // `fromHash` builds the rule here rather than accepting a transcribed copy.
139
+ // The pinning gates below then run against the rule we just synthesized, so
140
+ // this path is gated identically to a caller-supplied one - it is a shortcut
141
+ // for the caller, never for the checks.
142
+ let rule = input.rule;
143
+ if (rule === undefined && input.fromPredicate !== undefined) {
144
+ const fp = input.fromPredicate;
145
+ let scope;
146
+ try {
147
+ scope = contextTypeForPredicate(decodePredicate(fp.encodedPredicate));
148
+ }
149
+ catch (e) {
150
+ return toolFailure('install_policy', e);
151
+ }
152
+ rule = {
153
+ contextRuleType: scope,
154
+ name: fp.name ?? 'policy',
155
+ validUntilLedger: fp.validUntilLedger ?? null,
156
+ signers: fp.signers.map((address) => ({ kind: 'delegated', address })),
157
+ policies: [
158
+ {
159
+ kind: 'interpreter',
160
+ interpreterAddress: PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network],
161
+ predicateBlobBase64: fp.encodedPredicate,
162
+ },
163
+ ],
164
+ };
165
+ }
166
+ if (rule === undefined) {
167
+ // Typed rather than inline: every tool body takes `unknown`, so a
168
+ // misspelled key here would compile and fail only at runtime, as a
169
+ // validation error blamed on the caller. Naming the type restores the
170
+ // check on this hop.
171
+ const synthArgs = {
172
+ source: 'recording',
173
+ network,
174
+ transactionHash: input.fromHash?.transactionHash,
175
+ interpreter: { smartAccountAddress: input.smartAccount },
176
+ ...(input.fromHash?.userResponses !== undefined
177
+ ? { userResponses: input.fromHash.userResponses }
178
+ : {}),
179
+ };
180
+ const synthesized = await runSynthesizePolicy(synthArgs);
181
+ if (!synthesized.ok) {
182
+ return { ok: false, error: synthesized.error };
183
+ }
184
+ // The synthesizer saw a spend it could not bound. Installing anyway yields
185
+ // a rule that reads as a cap and enforces nothing, and nothing downstream
186
+ // catches it: it installs cleanly and verifies cleanly, because a missing
187
+ // constraint generates no deny case that could fail. That combination
188
+ // reached the chain once. Refuse rather than emit a warning to skim past.
189
+ const unbounded = synthesized.data.ambiguities.some((a) => a.code === 'AMOUNT_BOUND_MISSING');
190
+ if (unbounded && input.allowUnboundedAmount !== true) {
191
+ return {
192
+ ok: false,
193
+ error: {
194
+ code: 'INSTALL_BUILD_FAILED',
195
+ message: 'install_policy: the recorded call spends an amount this policy does not bound, so the rule would constrain everything about the call except how much it moves; set `fromHash.userResponses.limitAmount` to the per-call cap, or `allowUnboundedAmount: true` to install an unbounded rule deliberately',
196
+ severity: 'error',
197
+ retryable: false,
198
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
199
+ },
200
+ };
201
+ }
202
+ // Synthesis leaves the signer set empty - it reads a transaction, and which
203
+ // keys a rule binds is a security decision no single recording answers.
204
+ // The caller names them here.
205
+ rule = {
206
+ ...synthesized.data.contextRule,
207
+ signers: (input.fromHash?.signers ?? []).map((address) => ({
208
+ kind: 'delegated',
209
+ address,
210
+ })),
211
+ };
212
+ }
213
+ // A rule that governs no key is refused on chain, and the refusal arrives as
214
+ // a bare contract error code with nothing to act on. Say what is missing
215
+ // instead, while the caller still has the recording in hand.
216
+ if (rule.signers.length === 0) {
217
+ return {
218
+ ok: false,
219
+ error: {
220
+ code: 'INSTALL_BUILD_FAILED',
221
+ message: 'install_policy: the rule names no signer, so it would govern no key; name the keys it applies to',
222
+ severity: 'error',
223
+ retryable: false,
224
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
225
+ },
226
+ };
227
+ }
120
228
  // ---- Pinning gates (default-deny) ----
121
229
  const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
122
230
  const expectedRpc = RPC_URL_BY_NETWORK[network];
123
- const pinningError = enforceInterpreterPin(input.rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
231
+ // Synthesis stamps every interpreter policy with the placeholder marker: it
232
+ // is handed a recording, not a network, so it emits a marker rather than
233
+ // inventing a deploy address. Install DOES know the network, and resolves
234
+ // the pin just above, so it fills the marker in here - otherwise the
235
+ // synthesize -> install path is unreachable, because the marker is not a
236
+ // strkey and fails the pin on every call. Only the exact marker is replaced;
237
+ // a caller-supplied address is still checked against the pin unchanged, so
238
+ // this widens nothing.
239
+ rule = {
240
+ ...rule,
241
+ policies: rule.policies.map((p) => p.kind === 'interpreter' && p.interpreterAddress === PLACEHOLDER_INTERPRETER_ADDRESS
242
+ ? { ...p, interpreterAddress: expectedInterpreter }
243
+ : p),
244
+ };
245
+ // A rolling total, when asked for. The predicate bounds each call; this
246
+ // bounds the sum across calls, which is state the interpreter does not keep.
247
+ // Both sit on the one rule and compose as all-of.
248
+ if (input.spendingLimit !== undefined) {
249
+ if (rule.contextRuleType.kind !== 'call_contract') {
250
+ return {
251
+ ok: false,
252
+ error: {
253
+ code: 'INSTALL_BUILD_FAILED',
254
+ message: `install_policy: a spending limit meters transfers of one token, so the rule must be scoped to that token's contract; this rule's scope is "${rule.contextRuleType.kind}"`,
255
+ severity: 'error',
256
+ retryable: false,
257
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
258
+ },
259
+ };
260
+ }
261
+ rule = {
262
+ ...rule,
263
+ policies: [
264
+ ...rule.policies,
265
+ {
266
+ kind: 'spending_limit',
267
+ policyAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
268
+ periodLedgers: input.spendingLimit.periodLedgers,
269
+ spendingLimit: input.spendingLimit.amount,
270
+ },
271
+ ],
272
+ };
273
+ }
274
+ const pinningError = enforceInterpreterPin(rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
124
275
  if (pinningError) {
125
276
  return { ok: false, error: pinningError };
126
277
  }
@@ -136,7 +287,7 @@ export async function runInstallPolicy(raw) {
136
287
  return toolFailure('install_policy', e);
137
288
  }
138
289
  try {
139
- const interpreterPolicy = input.rule.policies.find((p) => p.kind === 'interpreter');
290
+ const interpreterPolicy = rule.policies.find((p) => p.kind === 'interpreter');
140
291
  const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? '';
141
292
  const predicateHash = createHash('sha256')
142
293
  .update(Buffer.from(encodedPredicate, 'base64'))
@@ -145,8 +296,10 @@ export async function runInstallPolicy(raw) {
145
296
  smartAccount: input.smartAccount,
146
297
  sourceAccount: input.sourceAccount,
147
298
  networkPassphrase: NETWORK_PASSPHRASES[network],
148
- rule: input.rule,
149
- installNonce: input.installNonce,
299
+ rule,
300
+ // A fresh rule has no stored nonce, so 1 is the value the interpreter
301
+ // expects unless the caller is deliberately re-installing.
302
+ installNonce: input.installNonce ?? 1,
150
303
  encodedPredicate,
151
304
  predicateHash,
152
305
  rpc: rpcClient,
@@ -171,8 +324,8 @@ export async function runInstallPolicy(raw) {
171
324
  // no existing rule this install replaces. A sentinel no real id
172
325
  // can equal keeps every observed rule in scope.
173
326
  ruleId: -1,
174
- contextType: input.rule.contextRuleType,
175
- signers: input.rule.signers,
327
+ contextType: rule.contextRuleType,
328
+ signers: rule.signers,
176
329
  predicate: decodePredicate(encodedPredicate),
177
330
  },
178
331
  existing: observed,
@@ -266,23 +419,116 @@ function noInvocationError(toolName) {
266
419
  retryable: false,
267
420
  };
268
421
  }
422
+ /** Scope a rule to whatever contract its predicate pins.
423
+ *
424
+ * Taking this from the predicate rather than from a separate argument means
425
+ * the rule's scope cannot drift from what the predicate actually checks. A
426
+ * predicate that pins no contract yields the default (account-wide) type,
427
+ * which is what an unpinned predicate means. Only the top level is walked:
428
+ * a contract pin nested under an `or` does not scope the rule, because the
429
+ * other branch would not be covered by it. */
430
+ export function contextTypeForPredicate(predicate) {
431
+ const conjuncts = predicate.op === 'and' ? predicate.children : [predicate];
432
+ for (const node of conjuncts) {
433
+ if (node.op !== 'eq')
434
+ continue;
435
+ if (node.left?.kind !== 'call_contract')
436
+ continue;
437
+ if (node.right?.kind !== 'literal_address')
438
+ continue;
439
+ return { kind: 'call_contract', contract: node.right.value };
440
+ }
441
+ return { kind: 'default' };
442
+ }
443
+ /** Resolve what `simulate_policy` and `verify_policy` evaluate.
444
+ *
445
+ * Both want a predicate TREE plus the recording it came from, and neither is
446
+ * something a caller holds by default: the tree is only returned by
447
+ * `synthesize_policy` under `explain`, so a caller who did not ask for it has
448
+ * nothing to pass and skips the check. Skipping is the worst outcome here -
449
+ * these two ARE the check - so a transaction hash is accepted instead and the
450
+ * server rebuilds both from it. Recording is deterministic for a settled
451
+ * transaction, so this evaluates the same predicate the synthesiser produced. */
452
+ async function resolveCheckInputs(input, tool) {
453
+ const network = input.network ?? 'testnet';
454
+ // The call to check against: whichever the caller supplied, recording only
455
+ // when they gave a hash instead.
456
+ let permitTx;
457
+ if (input.permitTx !== undefined) {
458
+ permitTx = input.permitTx;
459
+ }
460
+ else {
461
+ const recordArgs = { hash: input.transactionHash, network };
462
+ const recorded = await runRecordTransaction(recordArgs);
463
+ if (!recorded.ok)
464
+ return { ok: false, error: recorded.error };
465
+ permitTx = recorded.data;
466
+ }
467
+ // The thing to check. A caller-supplied predicate wins over re-synthesis,
468
+ // in either form: a DECLARED policy has no recording behind it, so
469
+ // re-deriving one from the transaction would check a different predicate
470
+ // than the one the caller is asking about.
471
+ if (input.predicate !== undefined) {
472
+ return { ok: true, data: { predicate: input.predicate, permitTx } };
473
+ }
474
+ if (input.encodedPredicate !== undefined) {
475
+ try {
476
+ return { ok: true, data: { predicate: decodePredicate(input.encodedPredicate), permitTx } };
477
+ }
478
+ catch (e) {
479
+ return toolFailure(tool, e);
480
+ }
481
+ }
482
+ const synthArgs = {
483
+ source: 'recording',
484
+ network,
485
+ // The schema's inferred type is `passthrough`, so it carries an index
486
+ // signature the core type does not; the shapes agree field for field.
487
+ recordedTx: permitTx,
488
+ explain: true,
489
+ ...(input.smartAccount !== undefined
490
+ ? { interpreter: { smartAccountAddress: input.smartAccount } }
491
+ : {}),
492
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
493
+ };
494
+ const synthesized = await runSynthesizePolicy(synthArgs);
495
+ if (!synthesized.ok)
496
+ return { ok: false, error: synthesized.error };
497
+ const tree = synthesized.explain?.predicateTree;
498
+ if (!tree) {
499
+ return {
500
+ ok: false,
501
+ error: {
502
+ code: TOOL_ERROR_CODE[tool],
503
+ message: `${tool}: synthesis produced no predicate to check for that transaction`,
504
+ severity: 'error',
505
+ retryable: false,
506
+ remediation: { toolCall: { name: tool, args: {} } },
507
+ },
508
+ };
509
+ }
510
+ return { ok: true, data: { predicate: tree, permitTx } };
511
+ }
269
512
  /** `simulate_policy` body - evaluate a predicate against one recorded call.
270
513
  *
271
514
  * The evaluator is a second implementation of the on-chain semantics, and the
272
515
  * conformance harness asserts it agrees with the Rust interpreter case for
273
516
  * case. A verdict here is therefore a claim about what the contract would do,
274
517
  * not a guess. */
275
- export function runSimulatePolicy(raw) {
518
+ export async function runSimulatePolicy(raw) {
276
519
  const parsed = SimulatePolicyInputSchema.safeParse(raw);
277
520
  if (!parsed.success) {
278
521
  return { ok: false, error: validationError('simulate_policy', parsed.error.issues) };
279
522
  }
280
523
  const input = parsed.data;
281
- const ctx = evalContextFromRecording(input.permitTx);
524
+ const resolved = await resolveCheckInputs(input, 'simulate_policy');
525
+ if (!resolved.ok)
526
+ return { ok: false, error: resolved.error };
527
+ const ctx = evalContextFromRecording(resolved.data.permitTx);
282
528
  if (!ctx)
283
529
  return { ok: false, error: noInvocationError('simulate_policy') };
284
530
  try {
285
- const res = evaluate(input.predicate, ctx);
531
+ const res = evaluate(resolved.data.predicate, ctx);
286
532
  return {
287
533
  ok: true,
288
534
  data: {
@@ -356,17 +602,20 @@ export function runDeclarePolicy(raw) {
356
602
  * very transaction it was synthesised from. A deny case that permits means it
357
603
  * is too LOOSE: some mutation of that transaction still gets through. `ok` is
358
604
  * true only when neither holds. */
359
- export function runVerifyPolicy(raw) {
605
+ export async function runVerifyPolicy(raw) {
360
606
  const parsed = VerifyPolicyInputSchema.safeParse(raw);
361
607
  if (!parsed.success) {
362
608
  return { ok: false, error: validationError('verify_policy', parsed.error.issues) };
363
609
  }
364
610
  const input = parsed.data;
365
- const ctx = evalContextFromRecording(input.permitTx);
611
+ const resolved = await resolveCheckInputs(input, 'verify_policy');
612
+ if (!resolved.ok)
613
+ return { ok: false, error: resolved.error };
614
+ const ctx = evalContextFromRecording(resolved.data.permitTx);
366
615
  if (!ctx)
367
616
  return { ok: false, error: noInvocationError('verify_policy') };
368
617
  try {
369
- const predicate = input.predicate;
618
+ const predicate = resolved.data.predicate;
370
619
  const cases = generateCases(predicate, ctx);
371
620
  const permitRes = evaluate(predicate, cases.permit);
372
621
  const denies = cases.denies.map((d) => {