@crediolabs/policy-synth 0.5.6 → 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
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 {