@crediolabs/policy-synth 1.0.0 → 1.1.1

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 +41 -0
  3. package/dist/install/build-install-policy.js +51 -5
  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 +2279 -476
  9. package/dist/run/schemas.js +192 -26
  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 +41 -0
  16. package/dist-cjs/install/build-install-policy.js +52 -5
  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 +2279 -476
  22. package/dist-cjs/run/schemas.js +193 -27
  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 +100 -12
  30. package/src/predicate/encode.ts +5 -1
  31. package/src/run/index.ts +280 -23
  32. package/src/run/schemas.ts +229 -43
  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
@@ -21,6 +21,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.rpcClientFromServer = rpcClientFromServer;
22
22
  exports.buildInstallPolicyXdr = buildInstallPolicyXdr;
23
23
  exports.buildRevokePolicyXdr = buildRevokePolicyXdr;
24
+ exports.simulationReason = simulationReason;
24
25
  const node_crypto_1 = require("node:crypto");
25
26
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
26
27
  const build_add_context_rule_ts_1 = require("./build-add-context-rule.js");
@@ -67,6 +68,14 @@ function rpcClientFromServer(server, networkPassphrase) {
67
68
  },
68
69
  };
69
70
  }
71
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
72
+ function xdrIntegrity(unsignedXdr) {
73
+ return {
74
+ unsignedXdr,
75
+ unsignedXdrLength: unsignedXdr.length,
76
+ unsignedXdrSha256: (0, node_crypto_1.createHash)('sha256').update(unsignedXdr, 'utf8').digest('hex'),
77
+ };
78
+ }
70
79
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
71
80
  * The output XDR is signed by the wallet, not by us. */
72
81
  async function buildInstallPolicyXdr(args) {
@@ -95,7 +104,7 @@ async function buildInstallPolicyXdr(args) {
95
104
  // The human approval binds to the exact bytes the wallet will sign.
96
105
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
97
106
  return {
98
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
107
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
99
108
  smartAccount: args.smartAccount,
100
109
  sourceAccount: args.sourceAccount,
101
110
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -124,7 +133,7 @@ async function buildRevokePolicyXdr(args) {
124
133
  // consumer supplies only the ordinary envelope signature.
125
134
  const { finalTx, original, validUntilLedger } = await buildAuthorisedSmartAccountTx(args, 'remove_context_rule', [stellar_sdk_1.xdr.ScVal.scvU32(args.ruleId)], 'revoke_policy');
126
135
  return {
127
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
136
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
128
137
  smartAccount: args.smartAccount,
129
138
  sourceAccount: args.sourceAccount,
130
139
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -136,6 +145,21 @@ async function buildRevokePolicyXdr(args) {
136
145
  /** ~25 minutes at 5s/ledger. */
137
146
  const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
138
147
  // ---- internals ----
148
+ /** The actionable half of a failed simulation, with the transport half left out.
149
+ *
150
+ * `sim.error` names both why the chain refused the call and which host was
151
+ * asked, and the second half must not reach a caller. So we return only
152
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
153
+ * are what tells an operator whether the source account lacks authority, a
154
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
155
+ * failed" names nothing a caller can act on.
156
+ *
157
+ * Returns "" when the error carries no such form, so the caller keeps its short
158
+ * stable message rather than gaining an empty parenthesis. */
159
+ function simulationReason(sim) {
160
+ const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])];
161
+ return codes.length > 0 ? ` (${codes.join(', ')})` : '';
162
+ }
139
163
  /** Record a bare call to the smart account, attach the deploy-time admin rule's
140
164
  * auth entries, and re-simulate to assemble the footprint.
141
165
  *
@@ -165,8 +189,10 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
165
189
  // Short, stable reason. The full `simulateTransaction` error (which
166
190
  // carries host + URL detail) stays in the SDK's own logs - never
167
191
  // reflected back into a user-facing message where it would
168
- // reconnoitre the RPC.
169
- throw new Error(`${errorPrefix}: simulateTransaction failed`);
192
+ // reconnoitre the RPC. `simulationReason` re-adds only the chain's own
193
+ // error codes, which say why the call was refused without saying where
194
+ // the RPC lives.
195
+ throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`);
170
196
  }
171
197
  const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
172
198
  stellar_sdk_1.Address.fromScAddress(entry.credentials().address().address()).toString() ===
@@ -185,7 +211,7 @@ async function buildAuthorisedSmartAccountTx(args, functionName, callArgs, error
185
211
  const txWithAuth = buildTx(makeOperation(authEntries));
186
212
  const enforcing = await args.rpc.simulateTransaction(txWithAuth);
187
213
  if (stellar_sdk_1.rpc.Api.isSimulationError(enforcing)) {
188
- throw new Error(`${errorPrefix}: auth simulateTransaction failed`);
214
+ throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`);
189
215
  }
190
216
  return {
191
217
  finalTx: stellar_sdk_1.rpc.assembleTransaction(txWithAuth, enforcing).build(),
@@ -359,6 +385,27 @@ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
359
385
  observedInstallNonce = installNonce;
360
386
  continue;
361
387
  }
388
+ // OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
389
+ if (fields.has('period_ledgers') || fields.has('spending_limit')) {
390
+ const periodScv = fields.get('period_ledgers');
391
+ if (periodScv?.switch().name !== 'scvU32') {
392
+ throw new Error(`install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`);
393
+ }
394
+ const limitScv = fields.get('spending_limit');
395
+ if (limitScv?.switch().name !== 'scvI128') {
396
+ throw new Error(`install_policy: spending_limit policy ${address} is missing an i128 spending_limit`);
397
+ }
398
+ const parts = limitScv.i128();
399
+ const spendingLimit = ((BigInt(parts.hi().toString()) << 64n) +
400
+ BigInt(parts.lo().toString())).toString();
401
+ policies.push({
402
+ kind: 'spending_limit',
403
+ address,
404
+ periodLedgers: periodScv.u32(),
405
+ spendingLimit,
406
+ });
407
+ continue;
408
+ }
362
409
  throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
363
410
  }
364
411
  // `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;
@@ -20,6 +20,7 @@
20
20
  //
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.encodePredicate = encodePredicate;
23
+ exports.scvI128FromDecimal = scvI128FromDecimal;
23
24
  const node_crypto_1 = require("node:crypto");
24
25
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
25
26
  const types_ts_1 = require("../types.js");
@@ -234,6 +235,10 @@ function scvAddressFromStrkey(strkey) {
234
235
  * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
235
236
  * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
236
237
  * bigint/string/number. */
238
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
239
+ * guard on the high word. Exported so the install builder encodes an
240
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
241
+ * implementation is how a value above 2^64 silently loses its high word. */
237
242
  function scvI128FromDecimal(decimal) {
238
243
  const v = BigInt(decimal);
239
244
  const hi = v >> 64n;
@@ -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;
@@ -23,6 +23,7 @@ exports.runRecordTransaction = runRecordTransaction;
23
23
  exports.runSynthesizePolicy = runSynthesizePolicy;
24
24
  exports.runInstallPolicy = runInstallPolicy;
25
25
  exports.runRevokePolicy = runRevokePolicy;
26
+ exports.contextTypeForPredicate = contextTypeForPredicate;
26
27
  exports.runSimulatePolicy = runSimulatePolicy;
27
28
  exports.runDeclarePolicy = runDeclarePolicy;
28
29
  exports.runVerifyPolicy = runVerifyPolicy;
@@ -30,6 +31,7 @@ exports.runGetInterpreterInfo = runGetInterpreterInfo;
30
31
  exports.caughtError = caughtError;
31
32
  const node_crypto_1 = require("node:crypto");
32
33
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
34
+ const adapter_ts_1 = require("../adapters/interpreter/adapter.js");
33
35
  const index_ts_1 = require("../index.js");
34
36
  const authority_overlap_ts_1 = require("../install/authority-overlap.js");
35
37
  const build_install_policy_ts_1 = require("../install/build-install-policy.js");
@@ -130,7 +132,24 @@ async function runSynthesizePolicy(raw) {
130
132
  }
131
133
  const input = parsed.data;
132
134
  try {
133
- const recorded = input.recordedTx;
135
+ // `hash` is the agent-friendly alternative to `recordedTx`: re-record here
136
+ // rather than make the caller retype a recording it cannot copy faithfully.
137
+ // A recording failure is returned as-is, so the caller sees why the hash was
138
+ // refused instead of a synthesis error about a payload it never sent.
139
+ let recorded;
140
+ if (input.recordedTx === undefined) {
141
+ const rerecorded = await runRecordTransaction({
142
+ hash: input.transactionHash,
143
+ network: input.network,
144
+ });
145
+ if (!rerecorded.ok) {
146
+ return { ok: false, error: rerecorded.error };
147
+ }
148
+ recorded = rerecorded.data;
149
+ }
150
+ else {
151
+ recorded = input.recordedTx;
152
+ }
134
153
  return await (0, index_ts_1.synthesizeFromRecording)(recorded, {
135
154
  network: input.network,
136
155
  ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
@@ -152,10 +171,143 @@ async function runInstallPolicy(raw) {
152
171
  }
153
172
  const input = parsed.data;
154
173
  const network = input.network ?? 'testnet';
174
+ // `fromHash` builds the rule here rather than accepting a transcribed copy.
175
+ // The pinning gates below then run against the rule we just synthesized, so
176
+ // this path is gated identically to a caller-supplied one - it is a shortcut
177
+ // for the caller, never for the checks.
178
+ let rule = input.rule;
179
+ if (rule === undefined && input.fromPredicate !== undefined) {
180
+ const fp = input.fromPredicate;
181
+ let scope;
182
+ try {
183
+ scope = contextTypeForPredicate((0, decode_ts_1.decodePredicate)(fp.encodedPredicate));
184
+ }
185
+ catch (e) {
186
+ return toolFailure('install_policy', e);
187
+ }
188
+ rule = {
189
+ contextRuleType: scope,
190
+ name: fp.name ?? 'policy',
191
+ validUntilLedger: fp.validUntilLedger ?? null,
192
+ signers: fp.signers.map((address) => ({ kind: 'delegated', address })),
193
+ policies: [
194
+ {
195
+ kind: 'interpreter',
196
+ interpreterAddress: schemas_ts_1.PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network],
197
+ predicateBlobBase64: fp.encodedPredicate,
198
+ },
199
+ ],
200
+ };
201
+ }
202
+ if (rule === undefined) {
203
+ // Typed rather than inline: every tool body takes `unknown`, so a
204
+ // misspelled key here would compile and fail only at runtime, as a
205
+ // validation error blamed on the caller. Naming the type restores the
206
+ // check on this hop.
207
+ const synthArgs = {
208
+ source: 'recording',
209
+ network,
210
+ transactionHash: input.fromHash?.transactionHash,
211
+ interpreter: { smartAccountAddress: input.smartAccount },
212
+ ...(input.fromHash?.userResponses !== undefined
213
+ ? { userResponses: input.fromHash.userResponses }
214
+ : {}),
215
+ };
216
+ const synthesized = await runSynthesizePolicy(synthArgs);
217
+ if (!synthesized.ok) {
218
+ return { ok: false, error: synthesized.error };
219
+ }
220
+ // The synthesizer saw a spend it could not bound. Installing anyway yields
221
+ // a rule that reads as a cap and enforces nothing, and nothing downstream
222
+ // catches it: it installs cleanly and verifies cleanly, because a missing
223
+ // constraint generates no deny case that could fail. That combination
224
+ // reached the chain once. Refuse rather than emit a warning to skim past.
225
+ const unbounded = synthesized.data.ambiguities.some((a) => a.code === 'AMOUNT_BOUND_MISSING');
226
+ if (unbounded && input.allowUnboundedAmount !== true) {
227
+ return {
228
+ ok: false,
229
+ error: {
230
+ code: 'INSTALL_BUILD_FAILED',
231
+ 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',
232
+ severity: 'error',
233
+ retryable: false,
234
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
235
+ },
236
+ };
237
+ }
238
+ // Synthesis leaves the signer set empty - it reads a transaction, and which
239
+ // keys a rule binds is a security decision no single recording answers.
240
+ // The caller names them here.
241
+ rule = {
242
+ ...synthesized.data.contextRule,
243
+ signers: (input.fromHash?.signers ?? []).map((address) => ({
244
+ kind: 'delegated',
245
+ address,
246
+ })),
247
+ };
248
+ }
249
+ // A rule that governs no key is refused on chain, and the refusal arrives as
250
+ // a bare contract error code with nothing to act on. Say what is missing
251
+ // instead, while the caller still has the recording in hand.
252
+ if (rule.signers.length === 0) {
253
+ return {
254
+ ok: false,
255
+ error: {
256
+ code: 'INSTALL_BUILD_FAILED',
257
+ message: 'install_policy: the rule names no signer, so it would govern no key; name the keys it applies to',
258
+ severity: 'error',
259
+ retryable: false,
260
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
261
+ },
262
+ };
263
+ }
155
264
  // ---- Pinning gates (default-deny) ----
156
265
  const expectedInterpreter = schemas_ts_1.PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network];
157
266
  const expectedRpc = schemas_ts_1.RPC_URL_BY_NETWORK[network];
158
- const pinningError = enforceInterpreterPin(input.rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
267
+ // Synthesis stamps every interpreter policy with the placeholder marker: it
268
+ // is handed a recording, not a network, so it emits a marker rather than
269
+ // inventing a deploy address. Install DOES know the network, and resolves
270
+ // the pin just above, so it fills the marker in here - otherwise the
271
+ // synthesize -> install path is unreachable, because the marker is not a
272
+ // strkey and fails the pin on every call. Only the exact marker is replaced;
273
+ // a caller-supplied address is still checked against the pin unchanged, so
274
+ // this widens nothing.
275
+ rule = {
276
+ ...rule,
277
+ policies: rule.policies.map((p) => p.kind === 'interpreter' && p.interpreterAddress === adapter_ts_1.PLACEHOLDER_INTERPRETER_ADDRESS
278
+ ? { ...p, interpreterAddress: expectedInterpreter }
279
+ : p),
280
+ };
281
+ // A rolling total, when asked for. The predicate bounds each call; this
282
+ // bounds the sum across calls, which is state the interpreter does not keep.
283
+ // Both sit on the one rule and compose as all-of.
284
+ if (input.spendingLimit !== undefined) {
285
+ if (rule.contextRuleType.kind !== 'call_contract') {
286
+ return {
287
+ ok: false,
288
+ error: {
289
+ code: 'INSTALL_BUILD_FAILED',
290
+ 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}"`,
291
+ severity: 'error',
292
+ retryable: false,
293
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
294
+ },
295
+ };
296
+ }
297
+ rule = {
298
+ ...rule,
299
+ policies: [
300
+ ...rule.policies,
301
+ {
302
+ kind: 'spending_limit',
303
+ policyAddress: schemas_ts_1.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
304
+ periodLedgers: input.spendingLimit.periodLedgers,
305
+ spendingLimit: input.spendingLimit.amount,
306
+ },
307
+ ],
308
+ };
309
+ }
310
+ const pinningError = enforceInterpreterPin(rule.policies, input.allowUnpinnedInterpreter, expectedInterpreter);
159
311
  if (pinningError) {
160
312
  return { ok: false, error: pinningError };
161
313
  }
@@ -171,7 +323,7 @@ async function runInstallPolicy(raw) {
171
323
  return toolFailure('install_policy', e);
172
324
  }
173
325
  try {
174
- const interpreterPolicy = input.rule.policies.find((p) => p.kind === 'interpreter');
326
+ const interpreterPolicy = rule.policies.find((p) => p.kind === 'interpreter');
175
327
  const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? '';
176
328
  const predicateHash = (0, node_crypto_1.createHash)('sha256')
177
329
  .update(Buffer.from(encodedPredicate, 'base64'))
@@ -180,8 +332,10 @@ async function runInstallPolicy(raw) {
180
332
  smartAccount: input.smartAccount,
181
333
  sourceAccount: input.sourceAccount,
182
334
  networkPassphrase: schemas_ts_1.NETWORK_PASSPHRASES[network],
183
- rule: input.rule,
184
- installNonce: input.installNonce,
335
+ rule,
336
+ // A fresh rule has no stored nonce, so 1 is the value the interpreter
337
+ // expects unless the caller is deliberately re-installing.
338
+ installNonce: input.installNonce ?? 1,
185
339
  encodedPredicate,
186
340
  predicateHash,
187
341
  rpc: rpcClient,
@@ -206,8 +360,8 @@ async function runInstallPolicy(raw) {
206
360
  // no existing rule this install replaces. A sentinel no real id
207
361
  // can equal keeps every observed rule in scope.
208
362
  ruleId: -1,
209
- contextType: input.rule.contextRuleType,
210
- signers: input.rule.signers,
363
+ contextType: rule.contextRuleType,
364
+ signers: rule.signers,
211
365
  predicate: (0, decode_ts_1.decodePredicate)(encodedPredicate),
212
366
  },
213
367
  existing: observed,
@@ -301,23 +455,116 @@ function noInvocationError(toolName) {
301
455
  retryable: false,
302
456
  };
303
457
  }
458
+ /** Scope a rule to whatever contract its predicate pins.
459
+ *
460
+ * Taking this from the predicate rather than from a separate argument means
461
+ * the rule's scope cannot drift from what the predicate actually checks. A
462
+ * predicate that pins no contract yields the default (account-wide) type,
463
+ * which is what an unpinned predicate means. Only the top level is walked:
464
+ * a contract pin nested under an `or` does not scope the rule, because the
465
+ * other branch would not be covered by it. */
466
+ function contextTypeForPredicate(predicate) {
467
+ const conjuncts = predicate.op === 'and' ? predicate.children : [predicate];
468
+ for (const node of conjuncts) {
469
+ if (node.op !== 'eq')
470
+ continue;
471
+ if (node.left?.kind !== 'call_contract')
472
+ continue;
473
+ if (node.right?.kind !== 'literal_address')
474
+ continue;
475
+ return { kind: 'call_contract', contract: node.right.value };
476
+ }
477
+ return { kind: 'default' };
478
+ }
479
+ /** Resolve what `simulate_policy` and `verify_policy` evaluate.
480
+ *
481
+ * Both want a predicate TREE plus the recording it came from, and neither is
482
+ * something a caller holds by default: the tree is only returned by
483
+ * `synthesize_policy` under `explain`, so a caller who did not ask for it has
484
+ * nothing to pass and skips the check. Skipping is the worst outcome here -
485
+ * these two ARE the check - so a transaction hash is accepted instead and the
486
+ * server rebuilds both from it. Recording is deterministic for a settled
487
+ * transaction, so this evaluates the same predicate the synthesiser produced. */
488
+ async function resolveCheckInputs(input, tool) {
489
+ const network = input.network ?? 'testnet';
490
+ // The call to check against: whichever the caller supplied, recording only
491
+ // when they gave a hash instead.
492
+ let permitTx;
493
+ if (input.permitTx !== undefined) {
494
+ permitTx = input.permitTx;
495
+ }
496
+ else {
497
+ const recordArgs = { hash: input.transactionHash, network };
498
+ const recorded = await runRecordTransaction(recordArgs);
499
+ if (!recorded.ok)
500
+ return { ok: false, error: recorded.error };
501
+ permitTx = recorded.data;
502
+ }
503
+ // The thing to check. A caller-supplied predicate wins over re-synthesis,
504
+ // in either form: a DECLARED policy has no recording behind it, so
505
+ // re-deriving one from the transaction would check a different predicate
506
+ // than the one the caller is asking about.
507
+ if (input.predicate !== undefined) {
508
+ return { ok: true, data: { predicate: input.predicate, permitTx } };
509
+ }
510
+ if (input.encodedPredicate !== undefined) {
511
+ try {
512
+ return { ok: true, data: { predicate: (0, decode_ts_1.decodePredicate)(input.encodedPredicate), permitTx } };
513
+ }
514
+ catch (e) {
515
+ return toolFailure(tool, e);
516
+ }
517
+ }
518
+ const synthArgs = {
519
+ source: 'recording',
520
+ network,
521
+ // The schema's inferred type is `passthrough`, so it carries an index
522
+ // signature the core type does not; the shapes agree field for field.
523
+ recordedTx: permitTx,
524
+ explain: true,
525
+ ...(input.smartAccount !== undefined
526
+ ? { interpreter: { smartAccountAddress: input.smartAccount } }
527
+ : {}),
528
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
529
+ };
530
+ const synthesized = await runSynthesizePolicy(synthArgs);
531
+ if (!synthesized.ok)
532
+ return { ok: false, error: synthesized.error };
533
+ const tree = synthesized.explain?.predicateTree;
534
+ if (!tree) {
535
+ return {
536
+ ok: false,
537
+ error: {
538
+ code: TOOL_ERROR_CODE[tool],
539
+ message: `${tool}: synthesis produced no predicate to check for that transaction`,
540
+ severity: 'error',
541
+ retryable: false,
542
+ remediation: { toolCall: { name: tool, args: {} } },
543
+ },
544
+ };
545
+ }
546
+ return { ok: true, data: { predicate: tree, permitTx } };
547
+ }
304
548
  /** `simulate_policy` body - evaluate a predicate against one recorded call.
305
549
  *
306
550
  * The evaluator is a second implementation of the on-chain semantics, and the
307
551
  * conformance harness asserts it agrees with the Rust interpreter case for
308
552
  * case. A verdict here is therefore a claim about what the contract would do,
309
553
  * not a guess. */
310
- function runSimulatePolicy(raw) {
554
+ async function runSimulatePolicy(raw) {
311
555
  const parsed = schemas_ts_1.SimulatePolicyInputSchema.safeParse(raw);
312
556
  if (!parsed.success) {
313
557
  return { ok: false, error: validationError('simulate_policy', parsed.error.issues) };
314
558
  }
315
559
  const input = parsed.data;
316
- const ctx = evalContextFromRecording(input.permitTx);
560
+ const resolved = await resolveCheckInputs(input, 'simulate_policy');
561
+ if (!resolved.ok)
562
+ return { ok: false, error: resolved.error };
563
+ const ctx = evalContextFromRecording(resolved.data.permitTx);
317
564
  if (!ctx)
318
565
  return { ok: false, error: noInvocationError('simulate_policy') };
319
566
  try {
320
- const res = (0, index_ts_2.evaluate)(input.predicate, ctx);
567
+ const res = (0, index_ts_2.evaluate)(resolved.data.predicate, ctx);
321
568
  return {
322
569
  ok: true,
323
570
  data: {
@@ -391,17 +638,20 @@ function runDeclarePolicy(raw) {
391
638
  * very transaction it was synthesised from. A deny case that permits means it
392
639
  * is too LOOSE: some mutation of that transaction still gets through. `ok` is
393
640
  * true only when neither holds. */
394
- function runVerifyPolicy(raw) {
641
+ async function runVerifyPolicy(raw) {
395
642
  const parsed = schemas_ts_1.VerifyPolicyInputSchema.safeParse(raw);
396
643
  if (!parsed.success) {
397
644
  return { ok: false, error: validationError('verify_policy', parsed.error.issues) };
398
645
  }
399
646
  const input = parsed.data;
400
- const ctx = evalContextFromRecording(input.permitTx);
647
+ const resolved = await resolveCheckInputs(input, 'verify_policy');
648
+ if (!resolved.ok)
649
+ return { ok: false, error: resolved.error };
650
+ const ctx = evalContextFromRecording(resolved.data.permitTx);
401
651
  if (!ctx)
402
652
  return { ok: false, error: noInvocationError('verify_policy') };
403
653
  try {
404
- const predicate = input.predicate;
654
+ const predicate = resolved.data.predicate;
405
655
  const cases = (0, index_ts_2.generateCases)(predicate, ctx);
406
656
  const permitRes = (0, index_ts_2.evaluate)(predicate, cases.permit);
407
657
  const denies = cases.denies.map((d) => {