@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
@@ -159,25 +159,63 @@ export interface InstallCallDescribes {
159
159
  * sha256 of the predicate blob actually embedded in the XDR - so a
160
160
  * mismatch between the wire bytes and the review card is detectable
161
161
  * by reading `describes`. */
162
- policies: Array<{
163
- kind: 'interpreter'
164
- address: string
165
- installNonce: number
166
- predicateHash: string
167
- predicateSha256OfEmbeddedBytes: string
168
- }>
162
+ policies: Array<
163
+ | {
164
+ kind: 'interpreter'
165
+ address: string
166
+ installNonce: number
167
+ predicateHash: string
168
+ predicateSha256OfEmbeddedBytes: string
169
+ }
170
+ | {
171
+ /** An OpenZeppelin built-in bounding the SUM across calls, which the
172
+ * predicate cannot: the interpreter sees one call and keeps no state. */
173
+ kind: 'spending_limit'
174
+ address: string
175
+ periodLedgers: number
176
+ spendingLimit: string
177
+ }
178
+ >
169
179
  /** The install nonce, decoded from the interpreter policy's
170
180
  * `install_nonce` field. Echoed at the top level for reviewer convenience;
171
181
  * the per-policy entry is the source of truth. */
172
182
  installNonce: number
173
183
  }
174
184
 
185
+ /** `unsignedXdr` plus the length and digest that prove it arrived whole. */
186
+ function xdrIntegrity(unsignedXdr: string): {
187
+ unsignedXdr: string
188
+ unsignedXdrLength: number
189
+ unsignedXdrSha256: string
190
+ } {
191
+ return {
192
+ unsignedXdr,
193
+ unsignedXdrLength: unsignedXdr.length,
194
+ unsignedXdrSha256: createHash('sha256').update(unsignedXdr, 'utf8').digest('hex'),
195
+ }
196
+ }
197
+
175
198
  /** Output of the install-policy build. The unsigned XDR is the wallet's
176
199
  * input; the captured auth nonce + invocation root make the response
177
200
  * self-describing for callers that want to inspect what they signed. */
178
201
  export interface BuildInstallPolicyResult {
179
202
  /** Unsigned Soroban transaction envelope, base64 XDR. */
180
203
  unsignedXdr: string
204
+ /** Length and SHA-256 of `unsignedXdr`, so a caller that has to move it can
205
+ * prove it arrived whole.
206
+ *
207
+ * This envelope runs to several thousand characters, and the only route
208
+ * from a tool result onto disk is the caller re-emitting it. A truncated
209
+ * copy is not obviously wrong - it fails later as
210
+ * "failed to decode XDR: xdr value invalid", which reads like a malformed
211
+ * transaction rather than a transport problem. Observed in practice: one of
212
+ * two envelopes written in the same session lost its tail and its base64
213
+ * length went from a multiple of four to `len % 4 == 3`.
214
+ *
215
+ * Check both before signing. They are cheap, and they turn a silent,
216
+ * fatal truncation into a retry. */
217
+ unsignedXdrLength: number
218
+ unsignedXdrSha256: string
181
219
  /** Smart account contract address (echo). */
182
220
  smartAccount: string
183
221
  /** Source account (echo) - the address that must sign. */
@@ -248,7 +286,7 @@ export async function buildInstallPolicyXdr(
248
286
  const describes = decodeInstallCallDescribes(finalTx, args.installNonce)
249
287
 
250
288
  return {
251
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
289
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
252
290
  smartAccount: args.smartAccount,
253
291
  sourceAccount: args.sourceAccount,
254
292
  call: { contract: args.smartAccount, fn: 'add_context_rule' },
@@ -292,7 +330,7 @@ export async function buildRevokePolicyXdr(args: {
292
330
  )
293
331
 
294
332
  return {
295
- unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
333
+ ...xdrIntegrity(finalTx.toEnvelope().toXDR().toString('base64')),
296
334
  smartAccount: args.smartAccount,
297
335
  sourceAccount: args.sourceAccount,
298
336
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -304,6 +342,11 @@ export async function buildRevokePolicyXdr(args: {
304
342
 
305
343
  export interface BuildRevokePolicyResult {
306
344
  unsignedXdr: string
345
+ /** Same integrity pair as the install result, for the same reason: a revoke
346
+ * envelope also has to reach a signer intact, and a truncated copy fails as
347
+ * a malformed transaction rather than as a transport error. */
348
+ unsignedXdrLength: number
349
+ unsignedXdrSha256: string
307
350
  smartAccount: string
308
351
  sourceAccount: string
309
352
  call: { contract: string; fn: 'remove_context_rule'; ruleId: number }
@@ -317,6 +360,22 @@ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
317
360
 
318
361
  // ---- internals ----
319
362
 
363
+ /** The actionable half of a failed simulation, with the transport half left out.
364
+ *
365
+ * `sim.error` names both why the chain refused the call and which host was
366
+ * asked, and the second half must not reach a caller. So we return only
367
+ * Soroban's own `Error(Type, #Code)` forms: those are contract state, and they
368
+ * are what tells an operator whether the source account lacks authority, a
369
+ * nonce is stale, or a predicate refused. Without them "simulateTransaction
370
+ * failed" names nothing a caller can act on.
371
+ *
372
+ * Returns "" when the error carries no such form, so the caller keeps its short
373
+ * stable message rather than gaining an empty parenthesis. */
374
+ export function simulationReason(sim: { error?: string }): string {
375
+ const codes = [...new Set((sim.error ?? '').match(/Error\([^)]*\)/g) ?? [])]
376
+ return codes.length > 0 ? ` (${codes.join(', ')})` : ''
377
+ }
378
+
320
379
  /** Record a bare call to the smart account, attach the deploy-time admin rule's
321
380
  * auth entries, and re-simulate to assemble the footprint.
322
381
  *
@@ -367,8 +426,10 @@ async function buildAuthorisedSmartAccountTx(
367
426
  // Short, stable reason. The full `simulateTransaction` error (which
368
427
  // carries host + URL detail) stays in the SDK's own logs - never
369
428
  // reflected back into a user-facing message where it would
370
- // reconnoitre the RPC.
371
- throw new Error(`${errorPrefix}: simulateTransaction failed`)
429
+ // reconnoitre the RPC. `simulationReason` re-adds only the chain's own
430
+ // error codes, which say why the call was refused without saying where
431
+ // the RPC lives.
432
+ throw new Error(`${errorPrefix}: simulateTransaction failed${simulationReason(recorded)}`)
372
433
  }
373
434
  const original = (recorded.result?.auth ?? []).find(
374
435
  (entry) =>
@@ -406,7 +467,7 @@ async function buildAuthorisedSmartAccountTx(
406
467
  const txWithAuth = buildTx(makeOperation(authEntries))
407
468
  const enforcing = await args.rpc.simulateTransaction(txWithAuth)
408
469
  if (rpc.Api.isSimulationError(enforcing)) {
409
- throw new Error(`${errorPrefix}: auth simulateTransaction failed`)
470
+ throw new Error(`${errorPrefix}: auth simulateTransaction failed${simulationReason(enforcing)}`)
410
471
  }
411
472
  return {
412
473
  finalTx: rpc.assembleTransaction(txWithAuth, enforcing).build(),
@@ -609,6 +670,33 @@ function decodeInstallCallDescribes(
609
670
  observedInstallNonce = installNonce
610
671
  continue
611
672
  }
673
+ // OpenZeppelin `spending_limit`: { period_ledgers: u32, spending_limit: i128 }.
674
+ if (fields.has('period_ledgers') || fields.has('spending_limit')) {
675
+ const periodScv = fields.get('period_ledgers')
676
+ if (periodScv?.switch().name !== 'scvU32') {
677
+ throw new Error(
678
+ `install_policy: spending_limit policy ${address} is missing a u32 period_ledgers`
679
+ )
680
+ }
681
+ const limitScv = fields.get('spending_limit')
682
+ if (limitScv?.switch().name !== 'scvI128') {
683
+ throw new Error(
684
+ `install_policy: spending_limit policy ${address} is missing an i128 spending_limit`
685
+ )
686
+ }
687
+ const parts = limitScv.i128()
688
+ const spendingLimit = (
689
+ (BigInt(parts.hi().toString()) << 64n) +
690
+ BigInt(parts.lo().toString())
691
+ ).toString()
692
+ policies.push({
693
+ kind: 'spending_limit',
694
+ address,
695
+ periodLedgers: periodScv.u32(),
696
+ spendingLimit,
697
+ })
698
+ continue
699
+ }
612
700
  throw new Error(
613
701
  `install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`
614
702
  )
@@ -285,7 +285,11 @@ function scvAddressFromStrkey(strkey: string): xdr.ScVal {
285
285
  * The inverse split is `hi = v >> 64n` (arithmetic right shift) and
286
286
  * `lo = v & 0xFFFF...`. The SDK's `Int64` constructor takes a signed
287
287
  * bigint/string/number. */
288
- function scvI128FromDecimal(decimal: string): xdr.ScVal {
288
+ /** Canonical i128 encoding of a base-10 decimal string, with the Int64 range
289
+ * guard on the high word. Exported so the install builder encodes an
290
+ * OpenZeppelin amount the same way a predicate literal is encoded - a second
291
+ * implementation is how a value above 2^64 silently loses its high word. */
292
+ export function scvI128FromDecimal(decimal: string): xdr.ScVal {
289
293
  const v = BigInt(decimal)
290
294
  const hi = v >> 64n
291
295
  const lo = v & UINT64_MAX
package/src/run/index.ts CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { createHash } from 'node:crypto'
21
21
  import { rpc } from '@stellar/stellar-sdk'
22
+ import { PLACEHOLDER_INTERPRETER_ADDRESS } from '../adapters/interpreter/adapter.ts'
22
23
  import {
23
24
  declarePredicate,
24
25
  type ErrorCode,
@@ -60,6 +61,7 @@ import {
60
61
  PINNED_INTERPRETER_ADDRESS_BY_NETWORK,
61
62
  PINNED_INTERPRETER_GRAMMAR_VERSION,
62
63
  PINNED_INTERPRETER_WASM_SHA256,
64
+ PINNED_OZ_POLICY_ADDRESS_BY_NETWORK,
63
65
  type RecordTransactionInput,
64
66
  RecordTransactionInputSchema,
65
67
  type RevokePolicyInput,
@@ -203,7 +205,23 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
203
205
  const input = parsed.data
204
206
 
205
207
  try {
206
- const recorded: RecordedTransaction = input.recordedTx as RecordedTransaction
208
+ // `hash` is the agent-friendly alternative to `recordedTx`: re-record here
209
+ // rather than make the caller retype a recording it cannot copy faithfully.
210
+ // A recording failure is returned as-is, so the caller sees why the hash was
211
+ // refused instead of a synthesis error about a payload it never sent.
212
+ let recorded: RecordedTransaction
213
+ if (input.recordedTx === undefined) {
214
+ const rerecorded = await runRecordTransaction({
215
+ hash: input.transactionHash,
216
+ network: input.network,
217
+ })
218
+ if (!rerecorded.ok) {
219
+ return { ok: false, error: rerecorded.error }
220
+ }
221
+ recorded = rerecorded.data
222
+ } else {
223
+ recorded = input.recordedTx as RecordedTransaction
224
+ }
207
225
  return await synthesizeFromRecording(recorded, {
208
226
  network: input.network,
209
227
  ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
@@ -227,11 +245,147 @@ export async function runInstallPolicy(
227
245
  }
228
246
  const input: InstallPolicyInput = parsed.data
229
247
  const network: Network = input.network ?? 'testnet'
248
+ // `fromHash` builds the rule here rather than accepting a transcribed copy.
249
+ // The pinning gates below then run against the rule we just synthesized, so
250
+ // this path is gated identically to a caller-supplied one - it is a shortcut
251
+ // for the caller, never for the checks.
252
+ let rule = input.rule
253
+ if (rule === undefined && input.fromPredicate !== undefined) {
254
+ const fp = input.fromPredicate
255
+ let scope: NonNullable<InstallPolicyInput['rule']>['contextRuleType']
256
+ try {
257
+ scope = contextTypeForPredicate(decodePredicate(fp.encodedPredicate))
258
+ } catch (e) {
259
+ return toolFailure('install_policy', e)
260
+ }
261
+ rule = {
262
+ contextRuleType: scope,
263
+ name: fp.name ?? 'policy',
264
+ validUntilLedger: fp.validUntilLedger ?? null,
265
+ signers: fp.signers.map((address) => ({ kind: 'delegated' as const, address })),
266
+ policies: [
267
+ {
268
+ kind: 'interpreter' as const,
269
+ interpreterAddress: PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network],
270
+ predicateBlobBase64: fp.encodedPredicate,
271
+ },
272
+ ],
273
+ }
274
+ }
275
+ if (rule === undefined) {
276
+ // Typed rather than inline: every tool body takes `unknown`, so a
277
+ // misspelled key here would compile and fail only at runtime, as a
278
+ // validation error blamed on the caller. Naming the type restores the
279
+ // check on this hop.
280
+ const synthArgs: SynthesizePolicyInput = {
281
+ source: 'recording',
282
+ network,
283
+ transactionHash: input.fromHash?.transactionHash,
284
+ interpreter: { smartAccountAddress: input.smartAccount },
285
+ ...(input.fromHash?.userResponses !== undefined
286
+ ? { userResponses: input.fromHash.userResponses }
287
+ : {}),
288
+ }
289
+ const synthesized = await runSynthesizePolicy(synthArgs)
290
+ if (!synthesized.ok) {
291
+ return { ok: false, error: synthesized.error }
292
+ }
293
+ // The synthesizer saw a spend it could not bound. Installing anyway yields
294
+ // a rule that reads as a cap and enforces nothing, and nothing downstream
295
+ // catches it: it installs cleanly and verifies cleanly, because a missing
296
+ // constraint generates no deny case that could fail. That combination
297
+ // reached the chain once. Refuse rather than emit a warning to skim past.
298
+ const unbounded = synthesized.data.ambiguities.some((a) => a.code === 'AMOUNT_BOUND_MISSING')
299
+ if (unbounded && input.allowUnboundedAmount !== true) {
300
+ return {
301
+ ok: false,
302
+ error: {
303
+ code: 'INSTALL_BUILD_FAILED',
304
+ message:
305
+ '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',
306
+ severity: 'error',
307
+ retryable: false,
308
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
309
+ },
310
+ }
311
+ }
312
+ // Synthesis leaves the signer set empty - it reads a transaction, and which
313
+ // keys a rule binds is a security decision no single recording answers.
314
+ // The caller names them here.
315
+ rule = {
316
+ ...synthesized.data.contextRule,
317
+ signers: (input.fromHash?.signers ?? []).map((address) => ({
318
+ kind: 'delegated' as const,
319
+ address,
320
+ })),
321
+ }
322
+ }
323
+ // A rule that governs no key is refused on chain, and the refusal arrives as
324
+ // a bare contract error code with nothing to act on. Say what is missing
325
+ // instead, while the caller still has the recording in hand.
326
+ if (rule.signers.length === 0) {
327
+ return {
328
+ ok: false,
329
+ error: {
330
+ code: 'INSTALL_BUILD_FAILED',
331
+ message:
332
+ 'install_policy: the rule names no signer, so it would govern no key; name the keys it applies to',
333
+ severity: 'error',
334
+ retryable: false,
335
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
336
+ },
337
+ }
338
+ }
230
339
  // ---- Pinning gates (default-deny) ----
231
340
  const expectedInterpreter = PINNED_INTERPRETER_ADDRESS_BY_NETWORK[network]
232
341
  const expectedRpc = RPC_URL_BY_NETWORK[network]
342
+ // Synthesis stamps every interpreter policy with the placeholder marker: it
343
+ // is handed a recording, not a network, so it emits a marker rather than
344
+ // inventing a deploy address. Install DOES know the network, and resolves
345
+ // the pin just above, so it fills the marker in here - otherwise the
346
+ // synthesize -> install path is unreachable, because the marker is not a
347
+ // strkey and fails the pin on every call. Only the exact marker is replaced;
348
+ // a caller-supplied address is still checked against the pin unchanged, so
349
+ // this widens nothing.
350
+ rule = {
351
+ ...rule,
352
+ policies: rule.policies.map((p) =>
353
+ p.kind === 'interpreter' && p.interpreterAddress === PLACEHOLDER_INTERPRETER_ADDRESS
354
+ ? { ...p, interpreterAddress: expectedInterpreter }
355
+ : p
356
+ ),
357
+ }
358
+ // A rolling total, when asked for. The predicate bounds each call; this
359
+ // bounds the sum across calls, which is state the interpreter does not keep.
360
+ // Both sit on the one rule and compose as all-of.
361
+ if (input.spendingLimit !== undefined) {
362
+ if (rule.contextRuleType.kind !== 'call_contract') {
363
+ return {
364
+ ok: false,
365
+ error: {
366
+ code: 'INSTALL_BUILD_FAILED',
367
+ 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}"`,
368
+ severity: 'error',
369
+ retryable: false,
370
+ remediation: { toolCall: { name: 'install_policy', args: {} } },
371
+ },
372
+ }
373
+ }
374
+ rule = {
375
+ ...rule,
376
+ policies: [
377
+ ...rule.policies,
378
+ {
379
+ kind: 'spending_limit' as const,
380
+ policyAddress: PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
381
+ periodLedgers: input.spendingLimit.periodLedgers,
382
+ spendingLimit: input.spendingLimit.amount,
383
+ },
384
+ ],
385
+ }
386
+ }
233
387
  const pinningError = enforceInterpreterPin(
234
- input.rule.policies,
388
+ rule.policies,
235
389
  input.allowUnpinnedInterpreter,
236
390
  expectedInterpreter
237
391
  )
@@ -255,7 +409,7 @@ export async function runInstallPolicy(
255
409
  return toolFailure('install_policy', e)
256
410
  }
257
411
  try {
258
- const interpreterPolicy = input.rule.policies.find((p) => p.kind === 'interpreter')
412
+ const interpreterPolicy = rule.policies.find((p) => p.kind === 'interpreter')
259
413
  const encodedPredicate = interpreterPolicy?.predicateBlobBase64 ?? ''
260
414
  const predicateHash = createHash('sha256')
261
415
  .update(Buffer.from(encodedPredicate, 'base64'))
@@ -264,8 +418,10 @@ export async function runInstallPolicy(
264
418
  smartAccount: input.smartAccount,
265
419
  sourceAccount: input.sourceAccount,
266
420
  networkPassphrase: NETWORK_PASSPHRASES[network],
267
- rule: input.rule,
268
- installNonce: input.installNonce,
421
+ rule,
422
+ // A fresh rule has no stored nonce, so 1 is the value the interpreter
423
+ // expects unless the caller is deliberately re-installing.
424
+ installNonce: input.installNonce ?? 1,
269
425
  encodedPredicate,
270
426
  predicateHash,
271
427
  rpc: rpcClient,
@@ -291,8 +447,8 @@ export async function runInstallPolicy(
291
447
  // no existing rule this install replaces. A sentinel no real id
292
448
  // can equal keeps every observed rule in scope.
293
449
  ruleId: -1,
294
- contextType: input.rule.contextRuleType,
295
- signers: input.rule.signers,
450
+ contextType: rule.contextRuleType,
451
+ signers: rule.signers,
296
452
  predicate: decodePredicate(encodedPredicate),
297
453
  },
298
454
  existing: observed,
@@ -394,26 +550,123 @@ function noInvocationError(toolName: 'simulate_policy' | 'verify_policy'): ToolE
394
550
  }
395
551
  }
396
552
 
553
+ /** Scope a rule to whatever contract its predicate pins.
554
+ *
555
+ * Taking this from the predicate rather than from a separate argument means
556
+ * the rule's scope cannot drift from what the predicate actually checks. A
557
+ * predicate that pins no contract yields the default (account-wide) type,
558
+ * which is what an unpinned predicate means. Only the top level is walked:
559
+ * a contract pin nested under an `or` does not scope the rule, because the
560
+ * other branch would not be covered by it. */
561
+ export function contextTypeForPredicate(
562
+ predicate: PredicateNode
563
+ ): NonNullable<InstallPolicyInput['rule']>['contextRuleType'] {
564
+ const conjuncts = predicate.op === 'and' ? predicate.children : [predicate]
565
+ for (const node of conjuncts) {
566
+ if (node.op !== 'eq') continue
567
+ if (node.left?.kind !== 'call_contract') continue
568
+ if (node.right?.kind !== 'literal_address') continue
569
+ return { kind: 'call_contract', contract: node.right.value }
570
+ }
571
+ return { kind: 'default' }
572
+ }
573
+
574
+ /** Resolve what `simulate_policy` and `verify_policy` evaluate.
575
+ *
576
+ * Both want a predicate TREE plus the recording it came from, and neither is
577
+ * something a caller holds by default: the tree is only returned by
578
+ * `synthesize_policy` under `explain`, so a caller who did not ask for it has
579
+ * nothing to pass and skips the check. Skipping is the worst outcome here -
580
+ * these two ARE the check - so a transaction hash is accepted instead and the
581
+ * server rebuilds both from it. Recording is deterministic for a settled
582
+ * transaction, so this evaluates the same predicate the synthesiser produced. */
583
+ async function resolveCheckInputs(
584
+ input: SimulatePolicyInput,
585
+ tool: 'simulate_policy' | 'verify_policy'
586
+ ): Promise<ToolResponse<{ predicate: PredicateNode; permitTx: RecordedTransaction }>> {
587
+ const network = input.network ?? 'testnet'
588
+
589
+ // The call to check against: whichever the caller supplied, recording only
590
+ // when they gave a hash instead.
591
+ let permitTx: RecordedTransaction
592
+ if (input.permitTx !== undefined) {
593
+ permitTx = input.permitTx as RecordedTransaction
594
+ } else {
595
+ const recordArgs: RecordTransactionInput = { hash: input.transactionHash, network }
596
+ const recorded = await runRecordTransaction(recordArgs)
597
+ if (!recorded.ok) return { ok: false, error: recorded.error }
598
+ permitTx = recorded.data
599
+ }
600
+
601
+ // The thing to check. A caller-supplied predicate wins over re-synthesis,
602
+ // in either form: a DECLARED policy has no recording behind it, so
603
+ // re-deriving one from the transaction would check a different predicate
604
+ // than the one the caller is asking about.
605
+ if (input.predicate !== undefined) {
606
+ return { ok: true, data: { predicate: input.predicate as PredicateNode, permitTx } }
607
+ }
608
+ if (input.encodedPredicate !== undefined) {
609
+ try {
610
+ return { ok: true, data: { predicate: decodePredicate(input.encodedPredicate), permitTx } }
611
+ } catch (e) {
612
+ return toolFailure(tool, e)
613
+ }
614
+ }
615
+
616
+ const synthArgs: SynthesizePolicyInput = {
617
+ source: 'recording',
618
+ network,
619
+ // The schema's inferred type is `passthrough`, so it carries an index
620
+ // signature the core type does not; the shapes agree field for field.
621
+ recordedTx: permitTx as SynthesizePolicyInput['recordedTx'],
622
+ explain: true,
623
+ ...(input.smartAccount !== undefined
624
+ ? { interpreter: { smartAccountAddress: input.smartAccount } }
625
+ : {}),
626
+ ...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
627
+ }
628
+ const synthesized = await runSynthesizePolicy(synthArgs)
629
+ if (!synthesized.ok) return { ok: false, error: synthesized.error }
630
+ const tree = synthesized.explain?.predicateTree
631
+ if (!tree) {
632
+ return {
633
+ ok: false,
634
+ error: {
635
+ code: TOOL_ERROR_CODE[tool],
636
+ message: `${tool}: synthesis produced no predicate to check for that transaction`,
637
+ severity: 'error',
638
+ retryable: false,
639
+ remediation: { toolCall: { name: tool, args: {} } },
640
+ },
641
+ }
642
+ }
643
+ return { ok: true, data: { predicate: tree, permitTx } }
644
+ }
645
+
397
646
  /** `simulate_policy` body - evaluate a predicate against one recorded call.
398
647
  *
399
648
  * The evaluator is a second implementation of the on-chain semantics, and the
400
649
  * conformance harness asserts it agrees with the Rust interpreter case for
401
650
  * case. A verdict here is therefore a claim about what the contract would do,
402
651
  * not a guess. */
403
- export function runSimulatePolicy(raw: unknown): ToolResponse<{
404
- permitted: boolean
405
- reason: string | null
406
- call: { contract: string; fn: string; argCount: number }
407
- }> {
652
+ export async function runSimulatePolicy(raw: unknown): Promise<
653
+ ToolResponse<{
654
+ permitted: boolean
655
+ reason: string | null
656
+ call: { contract: string; fn: string; argCount: number }
657
+ }>
658
+ > {
408
659
  const parsed = SimulatePolicyInputSchema.safeParse(raw)
409
660
  if (!parsed.success) {
410
661
  return { ok: false, error: validationError('simulate_policy', parsed.error.issues) }
411
662
  }
412
663
  const input: SimulatePolicyInput = parsed.data
413
- const ctx = evalContextFromRecording(input.permitTx as RecordedTransaction)
664
+ const resolved = await resolveCheckInputs(input, 'simulate_policy')
665
+ if (!resolved.ok) return { ok: false, error: resolved.error }
666
+ const ctx = evalContextFromRecording(resolved.data.permitTx)
414
667
  if (!ctx) return { ok: false, error: noInvocationError('simulate_policy') }
415
668
  try {
416
- const res = evaluate(input.predicate as PredicateNode, ctx)
669
+ const res = evaluate(resolved.data.predicate, ctx)
417
670
  return {
418
671
  ok: true,
419
672
  data: {
@@ -492,21 +745,25 @@ export function runDeclarePolicy(raw: unknown): ToolResponse<{
492
745
  * very transaction it was synthesised from. A deny case that permits means it
493
746
  * is too LOOSE: some mutation of that transaction still gets through. `ok` is
494
747
  * true only when neither holds. */
495
- export function runVerifyPolicy(raw: unknown): ToolResponse<{
496
- ok: boolean
497
- permit: { permitted: boolean; reason: string | null }
498
- denies: Array<{ dimension: string; denied: boolean; reason: string | null }>
499
- dimensionsCovered: number
500
- }> {
748
+ export async function runVerifyPolicy(raw: unknown): Promise<
749
+ ToolResponse<{
750
+ ok: boolean
751
+ permit: { permitted: boolean; reason: string | null }
752
+ denies: Array<{ dimension: string; denied: boolean; reason: string | null }>
753
+ dimensionsCovered: number
754
+ }>
755
+ > {
501
756
  const parsed = VerifyPolicyInputSchema.safeParse(raw)
502
757
  if (!parsed.success) {
503
758
  return { ok: false, error: validationError('verify_policy', parsed.error.issues) }
504
759
  }
505
760
  const input: VerifyPolicyInput = parsed.data
506
- const ctx = evalContextFromRecording(input.permitTx as RecordedTransaction)
761
+ const resolved = await resolveCheckInputs(input, 'verify_policy')
762
+ if (!resolved.ok) return { ok: false, error: resolved.error }
763
+ const ctx = evalContextFromRecording(resolved.data.permitTx)
507
764
  if (!ctx) return { ok: false, error: noInvocationError('verify_policy') }
508
765
  try {
509
- const predicate = input.predicate as PredicateNode
766
+ const predicate = resolved.data.predicate
510
767
  const cases = generateCases(predicate, ctx)
511
768
  const permitRes = evaluate(predicate, cases.permit)
512
769
  const denies = cases.denies.map((d) => {
@@ -645,7 +902,7 @@ function buildRpcClientFromInput(
645
902
  * policies are pinned. The caller resolves the expected pin per network;
646
903
  * this function stays pure so it is easy to test. */
647
904
  function enforceInterpreterPin(
648
- policies: InstallPolicyInput['rule']['policies'],
905
+ policies: NonNullable<InstallPolicyInput['rule']>['policies'],
649
906
  allowUnpinned: boolean | undefined,
650
907
  expectedInterpreterAddress: string
651
908
  ): ToolError | null {