@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
@@ -153,23 +153,43 @@ export const InterpreterOptionsSchema = z.object({
153
153
  installNonce: z.number().int().positive().optional(),
154
154
  })
155
155
 
156
- export const SynthesizePolicyInputSchema = z.object({
157
- source: z.literal('recording'),
158
- recordedTx: RecordedTransactionSchema,
159
- network: NetworkSchema,
160
- userResponses: ComposeUserResponsesSchema.optional(),
161
- confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
162
- interpreter: InterpreterOptionsSchema.optional(),
163
- // --explain opt-in. When true, the orchestrator attaches the
164
- // in-memory PredicateNode + the corresponding SimulationResult
165
- // (real one from the self-verify pipeline when the interpreter is
166
- // engaged, minimal honest value otherwise) to the success envelope.
167
- // Absent or false -> the success envelope is unchanged (byte-identical
168
- // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
169
- // (encodedPredicate, predicateHash, etc.) are never altered by enabling
170
- // explain.
171
- explain: z.boolean().optional(),
172
- })
156
+ export const SynthesizePolicyInputSchema = z
157
+ .object({
158
+ source: z.literal('recording'),
159
+ // Two ways to name the recording, because an MCP client has no variable to
160
+ // pass by reference. `recordedTx` is the full RecordedTransaction, which a
161
+ // programmatic caller can hand straight over from `record_transaction`. An
162
+ // agent cannot: it sees that output as text and has to retype it, and the
163
+ // payload is thousands of characters and a dozen levels deep, with exact
164
+ // i128 strings that do not survive the round trip. `transactionHash` lets the
165
+ // agent carry a 64-character handle instead and have the server re-record.
166
+ //
167
+ // Re-recording rather than caching keeps the server stateless (see
168
+ // build-install-policy.ts). Recording is deterministic for a settled
169
+ // transaction, so the second read returns the same thing as the first.
170
+ recordedTx: RecordedTransactionSchema.optional(),
171
+ transactionHash: z
172
+ .string()
173
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
174
+ .optional(),
175
+ network: NetworkSchema,
176
+ userResponses: ComposeUserResponsesSchema.optional(),
177
+ confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
178
+ interpreter: InterpreterOptionsSchema.optional(),
179
+ // --explain opt-in. When true, the orchestrator attaches the
180
+ // in-memory PredicateNode + the corresponding SimulationResult
181
+ // (real one from the self-verify pipeline when the interpreter is
182
+ // engaged, minimal honest value otherwise) to the success envelope.
183
+ // Absent or false -> the success envelope is unchanged (byte-identical
184
+ // to today). The flag is ADDITIVE: the existing ProposedPolicy fields
185
+ // (encodedPredicate, predicateHash, etc.) are never altered by enabling
186
+ // explain.
187
+ explain: z.boolean().optional(),
188
+ })
189
+ .refine((v) => v.recordedTx !== undefined || v.transactionHash !== undefined, {
190
+ message:
191
+ 'supply either `recordedTx` (the full recording) or `transactionHash` (and the server will record it)',
192
+ })
173
193
 
174
194
  export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>
175
195
 
@@ -271,11 +291,44 @@ export const PredicateNodeSchema: z.ZodType<unknown> = z.lazy(() =>
271
291
  // take the same input. A null predicate used to mean "OZ built-in policies
272
292
  // only"; that backend is gone, so every policy carries a predicate and there is
273
293
  // nothing to simulate without one.
274
- export const SimulatePolicyInputSchema = z.object({
275
- predicate: PredicateNodeSchema,
276
- permitTx: RecordedTransactionSchema,
277
- validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
278
- })
294
+ export const SimulatePolicyInputSchema = z
295
+ .object({
296
+ // Same two ways in as `synthesize_policy`, for the same reason. The tree
297
+ // is only returned under `explain`, so a caller who did not ask for it has
298
+ // nothing to pass here and skips the check entirely - which is the one
299
+ // step that must not be skippable by accident. `transactionHash` re-records and
300
+ // re-synthesizes, so the predicate checked is the predicate that was built.
301
+ predicate: PredicateNodeSchema.optional(),
302
+ /** The canonical encoding `declare_policy` and `synthesize_policy` both
303
+ * return. A DECLARED policy has no recording behind it, so re-synthesizing
304
+ * from a hash would check a different predicate than the one declared -
305
+ * and the tree is the shape callers mistype. One opaque string is the
306
+ * handle that path was missing. */
307
+ encodedPredicate: z.string().optional(),
308
+ permitTx: RecordedTransactionSchema.optional(),
309
+ transactionHash: z
310
+ .string()
311
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters')
312
+ .optional(),
313
+ network: NetworkSchema.optional(),
314
+ /** Needed only with `transactionHash`: lowering a recording to an interpreter
315
+ * predicate is scoped to the account it will be installed on, and the
316
+ * self-call gate is defined against it. */
317
+ smartAccount: z.string().optional(),
318
+ userResponses: ComposeUserResponsesSchema.optional(),
319
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
320
+ })
321
+ // Two halves, each satisfiable on its own terms: something to check, and a
322
+ // call to check it against. `transactionHash` alone answers both.
323
+ .refine(
324
+ (v) =>
325
+ v.transactionHash !== undefined ||
326
+ ((v.predicate !== undefined || v.encodedPredicate !== undefined) && v.permitTx !== undefined),
327
+ {
328
+ message:
329
+ 'supply `transactionHash` (and the server will record and synthesize), or a predicate (`predicate` tree or `encodedPredicate` string) together with `permitTx` or `transactionHash`',
330
+ }
331
+ )
279
332
  export type SimulatePolicyInput = z.infer<typeof SimulatePolicyInputSchema>
280
333
 
281
334
  export const VerifyPolicyInputSchema = SimulatePolicyInputSchema
@@ -350,13 +403,26 @@ const ContextRuleDraftSchema = z
350
403
  ])
351
404
  )
352
405
  .max(MAX_SIGNERS_PER_RULE),
406
+ /** Policies on one rule compose as ALL-OF, so an interpreter predicate and
407
+ * an OpenZeppelin built-in can sit together and both must permit. That
408
+ * pairing is what expresses a rolling total: the predicate bounds each
409
+ * call, the built-in bounds the sum across calls. */
353
410
  policies: z
354
411
  .array(
355
- z.object({
356
- kind: z.literal('interpreter'),
357
- interpreterAddress: z.string(),
358
- predicateBlobBase64: z.string().min(1),
359
- })
412
+ z.discriminatedUnion('kind', [
413
+ z.object({
414
+ kind: z.literal('interpreter'),
415
+ interpreterAddress: z.string(),
416
+ predicateBlobBase64: z.string().min(1),
417
+ }),
418
+ z.object({
419
+ kind: z.literal('spending_limit'),
420
+ policyAddress: z.string(),
421
+ /** LEDGERS, not seconds. */
422
+ periodLedgers: z.number().int().positive().max(U32_MAX),
423
+ spendingLimit: z.string().regex(/^[0-9]+$/),
424
+ }),
425
+ ])
360
426
  )
361
427
  .max(MAX_POLICIES_PER_RULE),
362
428
  })
@@ -429,6 +495,11 @@ export const RPC_URL_BY_NETWORK: Record<Network, string> = {
429
495
  * Provenance detail in `docs/audit/README.md` finding 7. */
430
496
  export type OzBuiltinPolicy = 'spending_limit' | 'simple_threshold' | 'weighted_threshold'
431
497
 
498
+ /** The upstream tag the deployed policy instances were built from. Exported so
499
+ * `scripts/upstream-drift-check.ts` can compare it against the latest upstream
500
+ * release; a tag recorded only in prose cannot be checked by anything. */
501
+ export const PINNED_OZ_STELLAR_CONTRACTS_TAG = 'v0.7.2'
502
+
432
503
  /** Instance addresses per network. Exported so consumers import the pin
433
504
  * instead of copying a literal - a copied address is how a testnet id ends up
434
505
  * being queried against mainnet, which returns `Error(Storage, MissingValue)`
@@ -558,10 +629,70 @@ export const InstallPolicyInputSchema = z
558
629
  * A caller that targets mainnet MUST set this to `mainnet` (the
559
630
  * pin and RPC pin do not move by themselves). */
560
631
  network: NetworkSchema.optional(),
561
- /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
562
- rule: ContextRuleDraftSchema,
563
- /** Per-rule install nonce; 1 for a fresh install. */
564
- installNonce: z.number().int().positive(),
632
+ /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape.
633
+ *
634
+ * Optional because of `fromHash` below. An agent cannot reliably retype the
635
+ * `contextRule` that `synthesize_policy` returned - it is nested, and the
636
+ * observed failures were exactly that: `validUntilLedger` sent as a string,
637
+ * `signers` as "", `policies` as an object instead of an array. Supplying
638
+ * `fromHash` instead lets the server rebuild the same rule it just
639
+ * produced, rather than asking the caller to transcribe it. */
640
+ rule: ContextRuleDraftSchema.optional(),
641
+ /** Build the rule here instead of receiving it: record this transaction,
642
+ * synthesize against `smartAccount`, and install the result. The
643
+ * agent-friendly counterpart to `rule`, and the same handle
644
+ * `synthesize_policy` accepts. */
645
+ fromHash: z
646
+ .object({
647
+ transactionHash: z
648
+ .string()
649
+ .regex(/^[0-9a-f]{64}$/, 'transaction hash must be 64 lowercase hex characters'),
650
+ /** The keys this rule governs. Synthesis cannot choose them: it reads a
651
+ * transaction, and which keys a rule binds is the caller's security
652
+ * decision, not an inference from one recording. Naming a key here
653
+ * attaches it as a delegated signer. A rule with no signer is refused
654
+ * on chain, so this is required in practice; the `rule` form remains
655
+ * the way to attach an external (verifier + key bytes) signer. */
656
+ signers: z
657
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
658
+ .max(MAX_SIGNERS_PER_RULE)
659
+ .optional(),
660
+ userResponses: ComposeUserResponsesSchema.optional(),
661
+ })
662
+ .optional(),
663
+ /** Install a predicate the caller ALREADY holds - the base64 string
664
+ * `declare_policy` returns.
665
+ *
666
+ * Without this there is no route from `declare_policy` to here:
667
+ * `fromHash` re-synthesizes from a recording and would discard the
668
+ * declared predicate, and `rule` means hand-building a draft that the
669
+ * tool boundary types as `unknown`, so the caller is guessing. An agent
670
+ * asked to do that invented a requirement to deploy a signer contract,
671
+ * which is not a thing - a delegated signer is a plain account address.
672
+ *
673
+ * The context rule type is taken FROM the predicate: if it pins a
674
+ * contract, the rule is scoped to that contract. One source of truth, so
675
+ * the rule's scope cannot drift from what the predicate actually checks. */
676
+ fromPredicate: z
677
+ .object({
678
+ encodedPredicate: z.string().min(1),
679
+ /** The keys this rule governs, as plain Stellar account addresses. */
680
+ signers: z
681
+ .array(z.string().refine(isStellarAddress, 'must be a Stellar address (G... or C...)'))
682
+ .min(1)
683
+ .max(MAX_SIGNERS_PER_RULE),
684
+ name: z.string().min(1).optional(),
685
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
686
+ })
687
+ .optional(),
688
+ /** Per-rule install nonce. Defaults to 1, which is the only correct value
689
+ * here: this tool builds `add_context_rule`, the account assigns a NEW
690
+ * rule id, and the interpreter has no stored nonce for a rule that does
691
+ * not exist yet. Required, it was undiscoverable - an agent has no way to
692
+ * read it, and asking cost a round trip on a value the server already
693
+ * knows. Supply it only to re-install over an existing rule, where the
694
+ * interpreter wants `stored_nonce + 1`. */
695
+ installNonce: z.number().int().positive().optional(),
565
696
  /** Optional RPC URL override. Defaults to the pinned RPC for the
566
697
  * selected `network` (testnet by default, mainnet when
567
698
  * `network: 'mainnet'`); the override is refused unless
@@ -573,6 +704,38 @@ export const InstallPolicyInputSchema = z
573
704
  * network because the caller's auth-digest binds to whatever the
574
705
  * RPC returned. */
575
706
  allowUnpinnedRpcUrl: z.boolean().optional(),
707
+ /** Attach an OpenZeppelin `spending_limit` beside the predicate, giving the
708
+ * rule a ROLLING TOTAL as well as a per-call bound. Both must permit,
709
+ * because policies on one rule compose as all-of.
710
+ *
711
+ * This is the only way to express "N per day": the interpreter is handed
712
+ * one call and keeps no state, so a predicate cannot add up spending
713
+ * across calls. Composes with all three ways of naming the rule.
714
+ *
715
+ * The primitive meters the third argument of a call named exactly
716
+ * `transfer` and requires a `call_contract` rule scope, so the rule must
717
+ * be pinned to the token whose transfers it meters. */
718
+ spendingLimit: z
719
+ .object({
720
+ /** Rolling total in the token's smallest unit. */
721
+ amount: z
722
+ .string()
723
+ .regex(/^[0-9]+$/, 'amount must be a base-10 integer in the smallest unit'),
724
+ /** Window length in LEDGERS. Stellar closes one in roughly five
725
+ * seconds, so a period in seconds is an approximation of this. */
726
+ periodLedgers: z.number().int().positive().max(U32_MAX),
727
+ })
728
+ .optional(),
729
+ /** Opt-in to installing a rule that bounds no amount, when the recording
730
+ * behind `fromHash` showed a spend.
731
+ *
732
+ * Default-deny, because the failure is silent and reads as success: such
733
+ * a rule installs cleanly, verifies cleanly - a missing constraint
734
+ * generates no deny case to fail - and caps nothing. That combination
735
+ * reached the chain once already. A rule with no spend to bound is
736
+ * unaffected; only the case the synthesizer explicitly flagged is
737
+ * refused. */
738
+ allowUnboundedAmount: z.boolean().optional(),
576
739
  /** Opt-in to pointing the rule's interpreter policy at any address
577
740
  * other than the pinned interpreter for the selected network.
578
741
  * Default-deny: a caller that controls the interpreter can permit
@@ -582,6 +745,13 @@ export const InstallPolicyInputSchema = z
582
745
  /** Base fee in stroops; defaults to BASE_FEE (100). */
583
746
  baseFee: z.number().int().positive().optional(),
584
747
  })
748
+ .refine(
749
+ (v) => v.rule !== undefined || v.fromHash !== undefined || v.fromPredicate !== undefined,
750
+ {
751
+ message:
752
+ 'name the rule one of three ways: `fromHash` (server records, synthesizes and installs), `fromPredicate` (a predicate you already hold, plus the keys it governs), or `rule` (the full ContextRuleDraft, for programmatic callers)',
753
+ }
754
+ )
585
755
  .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
586
756
  message: 'smartAccount and sourceAccount are required',
587
757
  })
@@ -32,12 +32,20 @@ export interface IntentFacts {
32
32
  }
33
33
 
34
34
  /** Lower a recorded transaction to the canonical IntentFacts. Pure (no
35
- * randomness, no clock); same `RecordedTransaction` -> byte-identical facts. */
36
- export function lower(tx: RecordedTransaction): IntentFacts {
35
+ * randomness, no clock); same `RecordedTransaction` -> byte-identical facts.
36
+ *
37
+ * `governedAccount` is the smart account the policy will be installed on, when
38
+ * one is known. It spends from itself while a wallet submits the transaction,
39
+ * so it counts as a spender alongside the source account. */
40
+ export function lower(tx: RecordedTransaction, governedAccount?: string): IntentFacts {
37
41
  const invocations = tx.invocations
38
42
  const callTargets = uniqueOrdered(invocations.map((i) => i.contract))
39
43
  const functionsByContract = groupFunctionsByContract(invocations)
40
- const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, tx.sourceAccount)
44
+ const spenders =
45
+ governedAccount !== undefined && governedAccount !== tx.sourceAccount
46
+ ? [tx.sourceAccount, governedAccount]
47
+ : [tx.sourceAccount]
48
+ const spendByToken = aggregateOutgoingSpend(tx.tokenMovements, spenders)
41
49
  const allowedPaths = extractPathsByContract(invocations)
42
50
  const sharedRouter = inferSharedRouter(invocations)
43
51
 
@@ -76,16 +84,22 @@ function groupFunctionsByContract(invocations: ContractInvocation[]): Record<str
76
84
  return out
77
85
  }
78
86
 
79
- /** Sum outgoing TokenMovement amounts per token, where `from === source`.
80
- * BigInt throughout; never lossy. Movements whose `from` does not match the
81
- * recorded source account are NOT counted (incoming yield, refund, etc.). */
87
+ /** Sum outgoing TokenMovement amounts per token, across every spender.
88
+ * BigInt throughout; never lossy. Movements whose `from` is none of the
89
+ * spenders are NOT counted (incoming yield, refund, etc.).
90
+ *
91
+ * A smart account spends from ITSELF while a wallet submits the transaction,
92
+ * so matching the source account alone missed the entire treasury case: the
93
+ * flow read as incoming-only, no amount bound was required, and a rule that
94
+ * capped nothing installed with every check green. The account a policy
95
+ * governs is a spender in its own right. */
82
96
  function aggregateOutgoingSpend(
83
97
  movements: TokenMovement[],
84
- sourceAccount: string
98
+ spenders: readonly string[]
85
99
  ): Record<string, string> {
86
100
  const totals = new Map<string, bigint>()
87
101
  for (const m of movements) {
88
- if (m.from !== sourceAccount) continue
102
+ if (!spenders.includes(m.from)) continue
89
103
  const current = totals.get(m.token) ?? 0n
90
104
  totals.set(m.token, current + BigInt(m.amount))
91
105
  }
@@ -207,7 +207,7 @@ function synthesizeFromRecordingInner(
207
207
  }
208
208
  }
209
209
 
210
- const facts = lower(tx)
210
+ const facts = lower(tx, opts.interpreter?.smartAccountAddress)
211
211
 
212
212
  const scopeRes = decideScope(facts, {
213
213
  network: opts.network,
package/src/types.ts CHANGED
@@ -94,12 +94,31 @@ export type SignerDraft =
94
94
  | { kind: 'delegated'; address: string }
95
95
  | { kind: 'external'; verifier: string; keyBytes: string }
96
96
 
97
- /** Reference to one policy attached to a context rule. */
98
- export type PolicyRef = {
99
- kind: 'interpreter'
100
- interpreterAddress: string
101
- predicateBlobBase64: string
102
- }
97
+ /** Reference to one policy attached to a context rule.
98
+ *
99
+ * Policies on one rule compose as ALL-OF, so a rule may carry our interpreter
100
+ * AND an OpenZeppelin built-in, and both must permit. That is what expresses a
101
+ * rolling total: the interpreter bounds each call, the built-in bounds the sum
102
+ * across calls - state the interpreter deliberately does not keep. */
103
+ export type PolicyRef =
104
+ | {
105
+ kind: 'interpreter'
106
+ interpreterAddress: string
107
+ predicateBlobBase64: string
108
+ }
109
+ | {
110
+ /** OpenZeppelin's `spending_limit`: a rolling total over a window of
111
+ * ledgers. It meters the third argument of a call named exactly
112
+ * `transfer` and refuses any rule scope other than `CallContract`. */
113
+ kind: 'spending_limit'
114
+ policyAddress: string
115
+ /** Window length in LEDGERS, not seconds. Stellar closes a ledger in
116
+ * roughly five seconds, so a period given in seconds is an
117
+ * approximation of this number and should be reported as one. */
118
+ periodLedgers: number
119
+ /** i128 as a base-10 string, in the token's smallest unit. */
120
+ spendingLimit: string
121
+ }
103
122
 
104
123
  /** Grammar version baked into the interpreter wasm, mirroring `SELF_VERSION` in
105
124
  * `contracts/policy-interpreter/src/version.rs`. Every value this package puts on