@crediolabs/policy-synth 0.3.1 → 0.4.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 (51) hide show
  1. package/dist/install/authority-overlap.d.ts +101 -0
  2. package/dist/install/authority-overlap.js +227 -0
  3. package/dist/install/index.d.ts +1 -0
  4. package/dist/install/index.js +4 -0
  5. package/dist/record/index.d.ts +10 -0
  6. package/dist/record/index.js +32 -1
  7. package/dist/record/rpc.d.ts +4 -0
  8. package/dist/record/rpc.js +4 -1
  9. package/dist/registry/identify.d.ts +10 -1
  10. package/dist/registry/identify.js +4 -1
  11. package/dist/registry/on-chain-spec.d.ts +37 -0
  12. package/dist/registry/on-chain-spec.js +152 -0
  13. package/dist/run/index.d.ts +25 -4
  14. package/dist/run/index.js +65 -4
  15. package/dist/run/schemas.d.ts +313 -0
  16. package/dist/run/schemas.js +52 -0
  17. package/dist/synth/declare.d.ts +30 -0
  18. package/dist/synth/declare.js +98 -0
  19. package/dist/synth/index.d.ts +1 -0
  20. package/dist/synth/index.js +1 -0
  21. package/dist-cjs/install/authority-overlap.d.ts +101 -0
  22. package/dist-cjs/install/authority-overlap.js +236 -0
  23. package/dist-cjs/install/index.d.ts +1 -0
  24. package/dist-cjs/install/index.js +13 -2
  25. package/dist-cjs/record/index.d.ts +10 -0
  26. package/dist-cjs/record/index.js +31 -0
  27. package/dist-cjs/record/rpc.d.ts +4 -0
  28. package/dist-cjs/record/rpc.js +7 -3
  29. package/dist-cjs/registry/identify.d.ts +10 -1
  30. package/dist-cjs/registry/identify.js +4 -0
  31. package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
  32. package/dist-cjs/registry/on-chain-spec.js +159 -0
  33. package/dist-cjs/run/index.d.ts +25 -4
  34. package/dist-cjs/run/index.js +65 -2
  35. package/dist-cjs/run/schemas.d.ts +313 -0
  36. package/dist-cjs/run/schemas.js +53 -1
  37. package/dist-cjs/synth/declare.d.ts +30 -0
  38. package/dist-cjs/synth/declare.js +101 -0
  39. package/dist-cjs/synth/index.d.ts +1 -0
  40. package/dist-cjs/synth/index.js +3 -1
  41. package/package.json +1 -1
  42. package/src/install/authority-overlap.ts +312 -0
  43. package/src/install/index.ts +20 -0
  44. package/src/record/index.ts +59 -2
  45. package/src/record/rpc.ts +4 -1
  46. package/src/registry/identify.ts +4 -1
  47. package/src/registry/on-chain-spec.ts +168 -0
  48. package/src/run/index.ts +79 -2
  49. package/src/run/schemas.ts +57 -0
  50. package/src/synth/declare.ts +157 -0
  51. package/src/synth/index.ts +5 -0
package/src/run/index.ts CHANGED
@@ -20,7 +20,9 @@
20
20
  import { createHash } from 'node:crypto'
21
21
  import { rpc } from '@stellar/stellar-sdk'
22
22
  import {
23
+ declarePredicate,
23
24
  type ErrorCode,
25
+ encodePredicate,
24
26
  type Network,
25
27
  type PredicateNode,
26
28
  type ProposedPolicy,
@@ -31,6 +33,11 @@ import {
31
33
  type ToolError,
32
34
  type ToolResponse,
33
35
  } from '../index.ts'
36
+ import {
37
+ type AuthorityOverlap,
38
+ findAuthorityOverlaps,
39
+ type ObservedRule,
40
+ } from '../install/authority-overlap.ts'
34
41
  import {
35
42
  type BuildInstallPolicyResult,
36
43
  type BuildRevokePolicyResult,
@@ -40,8 +47,10 @@ import {
40
47
  rpcClientFromServer,
41
48
  } from '../install/build-install-policy.ts'
42
49
  import { getInterpreterInfo } from '../install/get-interpreter-info.ts'
50
+ import { decodePredicate } from '../predicate/decode.ts'
43
51
  import { type EvalContext, evaluate, generateCases } from '../simulate/index.ts'
44
52
  import {
53
+ DeclarePolicyInputSchema,
45
54
  type GetInterpreterInfoInput,
46
55
  GetInterpreterInfoInputSchema,
47
56
  type InstallPolicyInput,
@@ -64,6 +73,7 @@ import {
64
73
  } from './schemas.ts'
65
74
 
66
75
  export type {
76
+ DeclarePolicyInput,
67
77
  GetInterpreterInfoInput,
68
78
  InstallPolicyInput,
69
79
  RecordTransactionInput,
@@ -78,6 +88,7 @@ export type {
78
88
  // truth - MCP tool shapes are derived from them.
79
89
  export {
80
90
  ComposeUserResponsesSchema,
91
+ DeclarePolicyInputSchema,
81
92
  GetInterpreterInfoInputSchema,
82
93
  InstallPolicyInputSchema,
83
94
  InterpreterOptionsSchema,
@@ -107,6 +118,7 @@ export type RunVerifyPolicyInput = VerifyPolicyInput
107
118
  type RunToolName =
108
119
  | 'record_transaction'
109
120
  | 'synthesize_policy'
121
+ | 'declare_policy'
110
122
  | 'simulate_policy'
111
123
  | 'verify_policy'
112
124
  | 'install_policy'
@@ -119,6 +131,7 @@ type RunToolName =
119
131
  const TOOL_ERROR_CODE: Record<RunToolName, ErrorCode> = {
120
132
  record_transaction: 'RECORDING_FAILED',
121
133
  synthesize_policy: 'SYNTHESIS_ERROR',
134
+ declare_policy: 'SYNTHESIS_ERROR',
122
135
  simulate_policy: 'SIMULATION_ERROR',
123
136
  verify_policy: 'VERIFICATION_FAILED',
124
137
  install_policy: 'INSTALL_BUILD_FAILED',
@@ -203,7 +216,7 @@ export async function runSynthesizePolicy(raw: unknown): Promise<
203
216
 
204
217
  export async function runInstallPolicy(
205
218
  raw: unknown
206
- ): Promise<ToolResponse<BuildInstallPolicyResult>> {
219
+ ): Promise<ToolResponse<BuildInstallPolicyResult & { authorityScan: AuthorityOverlap[] | null }>> {
207
220
  const parsed = InstallPolicyInputSchema.safeParse(raw)
208
221
  if (!parsed.success) {
209
222
  return { ok: false, error: validationError('install_policy', parsed.error.issues) }
@@ -254,7 +267,28 @@ export async function runInstallPolicy(
254
267
  rpc: rpcClient,
255
268
  ...(input.baseFee !== undefined ? { baseFee: input.baseFee } : {}),
256
269
  })
257
- return { ok: true, data: result }
270
+ // Cross-rule scan, when the caller supplied what else is on the account.
271
+ // ABSENT is reported as `null` rather than an empty list: "we did not
272
+ // look" and "we looked and found nothing" are different answers, and
273
+ // collapsing them would let a caller read silence as safety.
274
+ const authorityScan =
275
+ input.existingRules === undefined
276
+ ? null
277
+ : findAuthorityOverlaps({
278
+ intended: {
279
+ // `add_context_rule` gets its id FROM the account, so there is
280
+ // no existing rule this install replaces. A sentinel no real id
281
+ // can equal keeps every observed rule in scope.
282
+ ruleId: -1,
283
+ contextType: input.rule.contextRuleType,
284
+ signers: input.rule.signers,
285
+ predicate: decodePredicate(encodedPredicate),
286
+ },
287
+ // The schema types `predicate` loosely (it is the shared
288
+ // PredicateNodeSchema); the shape is already validated.
289
+ existing: input.existingRules as ObservedRule[],
290
+ })
291
+ return { ok: true, data: { ...result, authorityScan } }
258
292
  } catch (e) {
259
293
  return toolFailure('install_policy', e)
260
294
  }
@@ -384,6 +418,49 @@ export function runSimulatePolicy(raw: unknown): ToolResponse<{
384
418
  }
385
419
  }
386
420
 
421
+ /** `declare_policy` body - the DECLARATIVE front-end.
422
+ *
423
+ * `synthesize_policy` infers a predicate from a transaction that happened;
424
+ * this takes the constraint stated outright. No RPC, no decoding and no
425
+ * parseConfidence, so nothing here can be refused for a contract the registry
426
+ * does not recognise - which is most of the point of having it.
427
+ *
428
+ * The returned `warnings` are load-bearing, not decoration. An argument index
429
+ * the caller did not supply is DEFAULTED to the SEP-41 position, and a bound
430
+ * on the wrong argument constrains something the caller did not mean without
431
+ * ever announcing itself, so a caller that ignores warnings can install a
432
+ * predicate that reads correctly and binds nothing. */
433
+ export function runDeclarePolicy(raw: unknown): ToolResponse<{
434
+ predicate: PredicateNode
435
+ encodedPredicate: string
436
+ predicateHash: string
437
+ warnings: string[]
438
+ }> {
439
+ const parsed = DeclarePolicyInputSchema.safeParse(raw)
440
+ if (!parsed.success) {
441
+ return { ok: false, error: validationError('declare_policy', parsed.error.issues) }
442
+ }
443
+ try {
444
+ // Rebuilt field-by-field rather than passed through: the schema's
445
+ // optionals are `T | undefined` and `PolicyDeclaration`'s are absent-or-T,
446
+ // which `exactOptionalPropertyTypes` treats as different.
447
+ const d = parsed.data
448
+ const { predicate, warnings } = declarePredicate({
449
+ fn: d.fn,
450
+ ...(d.contract !== undefined ? { contract: d.contract } : {}),
451
+ ...(d.maxAmount !== undefined ? { maxAmount: d.maxAmount } : {}),
452
+ ...(d.amountArgIndex !== undefined ? { amountArgIndex: d.amountArgIndex } : {}),
453
+ ...(d.recipients !== undefined ? { recipients: d.recipients } : {}),
454
+ ...(d.recipientArgIndex !== undefined ? { recipientArgIndex: d.recipientArgIndex } : {}),
455
+ ...(d.allowZeroCap !== undefined ? { allowZeroCap: d.allowZeroCap } : {}),
456
+ })
457
+ const { encodedPredicate, predicateHash } = encodePredicate(predicate)
458
+ return { ok: true, data: { predicate, encodedPredicate, predicateHash, warnings } }
459
+ } catch (e) {
460
+ return toolFailure('declare_policy', e)
461
+ }
462
+ }
463
+
387
464
  /** `verify_policy` body - the permit case plus a generated deny case per
388
465
  * dimension.
389
466
  *
@@ -281,6 +281,29 @@ export type VerifyPolicyInput = z.infer<typeof VerifyPolicyInputSchema>
281
281
  const MAX_SIGNERS_PER_RULE = 15
282
282
  const MAX_POLICIES_PER_RULE = 5
283
283
 
284
+ const SignerDraftSchema = z.discriminatedUnion('kind', [
285
+ z.object({ kind: z.literal('delegated'), address: z.string() }),
286
+ z.object({ kind: z.literal('external'), verifier: z.string(), keyBytes: z.string() }),
287
+ ])
288
+
289
+ const ContextTypeSchema = z.discriminatedUnion('kind', [
290
+ z.object({ kind: z.literal('default') }),
291
+ z.object({ kind: z.literal('call_contract'), contract: z.string() }),
292
+ z.object({ kind: z.literal('create_contract'), wasmHash: z.string() }),
293
+ ])
294
+
295
+ /** A rule ALREADY on the account, as the caller observed it. Supplying these
296
+ * turns on the cross-rule authority scan: a signer belonging to several rules
297
+ * picks which one applies, so a predicate only constrains a key when the
298
+ * policed rule is the only rule that key is on. */
299
+ export const ObservedRuleSchema = z.object({
300
+ id: z.number().int().nonnegative(),
301
+ contextType: ContextTypeSchema,
302
+ signers: z.array(SignerDraftSchema),
303
+ policyAddresses: z.array(z.string()),
304
+ predicate: PredicateNodeSchema.optional(),
305
+ })
306
+
284
307
  const ContextRuleDraftSchema = z
285
308
  .object({
286
309
  contextRuleType: z.discriminatedUnion('kind', [
@@ -391,8 +414,42 @@ export const NETWORK_PASSPHRASES: Record<Network, string> = {
391
414
  const STELLAR_CONTRACT_ADDRESS = /^C[2-7A-Z]{55}$/
392
415
  const STELLAR_ACCOUNT_ADDRESS = /^G[2-7A-Z]{55}$/
393
416
 
417
+ // ===== declare_policy =====
418
+ //
419
+ // The declarative front-end: the constraint stated outright, with no
420
+ // transaction to decode. Deliberately NOT a revival of the removed
421
+ // `MandateSpec` - that carried a rolling `spendingLimit` the interpreter
422
+ // cannot evaluate and an `approvalThreshold` needing OZ primitives nobody
423
+ // deployed. Only fields grammar 3 can actually enforce appear here.
424
+ export const DeclarePolicyInputSchema = z
425
+ .object({
426
+ fn: z.string().min(1, 'fn must name the method to pin'),
427
+ contract: z
428
+ .string()
429
+ .regex(STELLAR_CONTRACT_ADDRESS, 'contract must be a Stellar contract address (C...)')
430
+ .optional(),
431
+ /** Smallest unit, unsigned decimal STRING - an i128 is wider than
432
+ * Number.MAX_SAFE_INTEGER, so a number here would silently round. */
433
+ maxAmount: z
434
+ .string()
435
+ .regex(/^[0-9]+$/, 'maxAmount must be an unsigned integer in the smallest unit')
436
+ .optional(),
437
+ amountArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
438
+ recipients: z.array(z.string()).min(1, 'recipients must not be empty').optional(),
439
+ recipientArgIndex: z.number().int().nonnegative().max(U32_MAX).optional(),
440
+ allowZeroCap: z.boolean().optional(),
441
+ })
442
+ .strict()
443
+ export type DeclarePolicyInput = z.infer<typeof DeclarePolicyInputSchema>
444
+
394
445
  export const InstallPolicyInputSchema = z
395
446
  .object({
447
+ /** Rules already on the account. Supplying them turns on the cross-rule
448
+ * authority scan, which reports every existing rule a signer of this
449
+ * install could name INSTEAD - including an unpoliced one, against which
450
+ * the predicate never runs. Absent means the scan is skipped, and the
451
+ * result says so rather than reporting "no overlaps found". */
452
+ existingRules: z.array(ObservedRuleSchema).optional(),
396
453
  /** The smart account contract address (C...) that will receive the rule. */
397
454
  smartAccount: z
398
455
  .string()
@@ -0,0 +1,157 @@
1
+ // src/synth/declare.ts - build a predicate from a DECLARED constraint.
2
+ //
3
+ // The second synthesis front-end, beside `synthesizeFromRecording`. That one
4
+ // infers a predicate from a transaction that happened; this one takes the
5
+ // constraint the user states outright. No transaction, no decoding, no
6
+ // inference, no RPC: the same declaration always lowers to a byte-identical
7
+ // predicate.
8
+ //
9
+ // This is the surviving half of the removed `MandateSpec`. Two of that type's
10
+ // five fields lowered to things that do not exist - `spendingLimit` became a
11
+ // `window_spent` compare the interpreter cannot evaluate (it is handed one
12
+ // call and keeps no state), and `approvalThreshold` needed OZ built-in policy
13
+ // contracts that were never deployed. Both are deliberately absent here, and
14
+ // the per-call `maxAmount` bound below is the honest replacement for the
15
+ // first: it constrains the amount in THIS call rather than implying a rolling
16
+ // total nothing tracks.
17
+ //
18
+ // What a declaration can say maps one-to-one onto grammar 3:
19
+ // fn -> eq(call_fn, literal_symbol)
20
+ // contract -> eq(call_contract, literal_address)
21
+ // maxAmount -> lte(call_arg(i), literal_i128)
22
+ // recipients -> in(call_arg(j), [literal_address, ...])
23
+
24
+ import type { ToolError } from '../errors.ts'
25
+ import type { PredicateLeaf, PredicateNode } from '../types.ts'
26
+ import { isStellarAddress } from './address.ts'
27
+
28
+ /** Argument positions of the SEP-41 `transfer(from, to, amount)` shape. A
29
+ * declaration that names a different method almost certainly has different
30
+ * positions, which is why using either default emits a warning naming the
31
+ * index it assumed - a bound on the wrong argument constrains something the
32
+ * user did not mean and fails silently. */
33
+ const SEP41_RECIPIENT_ARG = 1
34
+ const SEP41_AMOUNT_ARG = 2
35
+
36
+ export interface PolicyDeclaration {
37
+ /** Method to pin. Required: a predicate with no selector leaf constrains
38
+ * nothing and the contract refuses it at install. */
39
+ fn: string
40
+ /** Contract to pin, already resolved to a `C...` address. */
41
+ contract?: string
42
+ /** Upper bound on the call's amount argument, in the token's SMALLEST
43
+ * unit as an unsigned decimal string (25 XLM = "250000000"). */
44
+ maxAmount?: string
45
+ /** Which argument carries the amount. Defaults to the SEP-41 position. */
46
+ amountArgIndex?: number
47
+ /** Recipient allowlist. */
48
+ recipients?: string[]
49
+ /** Which argument carries the recipient. Defaults to the SEP-41 position. */
50
+ recipientArgIndex?: number
51
+ /** A cap of "0" denies every call, so it is refused unless asked for
52
+ * explicitly. A rule that permits nothing is a plausible thing to want and
53
+ * an implausible thing to want by accident. */
54
+ allowZeroCap?: boolean
55
+ }
56
+
57
+ export interface DeclaredPredicate {
58
+ predicate: PredicateNode
59
+ /** Assumptions the caller should check. Never empty when an argument index
60
+ * was defaulted rather than supplied. */
61
+ warnings: string[]
62
+ }
63
+
64
+ /** Lower a declared constraint to a grammar-3 predicate. Pure and total:
65
+ * the same declaration always produces the same predicate. */
66
+ export function declarePredicate(d: PolicyDeclaration): DeclaredPredicate {
67
+ if (!d.fn || d.fn.trim() === '') {
68
+ throw declareError('SYNTHESIS_ERROR', 'a declaration needs `fn`: the method to pin')
69
+ }
70
+ const warnings: string[] = []
71
+ const children: PredicateNode[] = [
72
+ { op: 'eq', left: { kind: 'call_fn' }, right: { kind: 'literal_symbol', value: d.fn } },
73
+ ]
74
+
75
+ if (d.contract !== undefined) {
76
+ if (!isStellarAddress(d.contract) || !d.contract.startsWith('C')) {
77
+ throw declareError(
78
+ 'SYNTHESIS_ERROR',
79
+ `contract must be a Stellar contract address (C...), got ${d.contract}`
80
+ )
81
+ }
82
+ children.push({
83
+ op: 'eq',
84
+ left: { kind: 'call_contract' },
85
+ right: { kind: 'literal_address', value: d.contract },
86
+ })
87
+ }
88
+
89
+ if (d.recipients !== undefined) {
90
+ if (d.recipients.length === 0) {
91
+ throw declareError(
92
+ 'SYNTHESIS_ERROR',
93
+ 'recipients was supplied but empty; an empty `in` haystack is refused at decode. Omit it to leave recipients unconstrained.'
94
+ )
95
+ }
96
+ for (const r of d.recipients) {
97
+ if (!isStellarAddress(r)) {
98
+ throw declareError('SYNTHESIS_ERROR', `recipient is not a Stellar address: ${r}`)
99
+ }
100
+ }
101
+ const idx = d.recipientArgIndex ?? SEP41_RECIPIENT_ARG
102
+ if (d.recipientArgIndex === undefined) {
103
+ warnings.push(
104
+ `recipient allowlist bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the recipient elsewhere this constrains the wrong argument - pass recipientArgIndex.`
105
+ )
106
+ }
107
+ children.push({
108
+ op: 'in',
109
+ needle: { kind: 'call_arg', index: idx },
110
+ haystack: d.recipients.map((value): PredicateLeaf => ({ kind: 'literal_address', value })),
111
+ })
112
+ }
113
+
114
+ if (d.maxAmount !== undefined) {
115
+ if (!/^[0-9]+$/.test(d.maxAmount)) {
116
+ throw declareError(
117
+ 'SYNTHESIS_ERROR',
118
+ `maxAmount must be an unsigned integer in the token's smallest unit, got "${d.maxAmount}" (25 XLM = "250000000")`
119
+ )
120
+ }
121
+ if (d.maxAmount === '0' && d.allowZeroCap !== true) {
122
+ throw declareError(
123
+ 'SYNTHESIS_ERROR',
124
+ 'maxAmount "0" denies every call: no amount satisfies the bound. Set allowZeroCap to declare that deliberately.'
125
+ )
126
+ }
127
+ const idx = d.amountArgIndex ?? SEP41_AMOUNT_ARG
128
+ if (d.amountArgIndex === undefined) {
129
+ warnings.push(
130
+ `amount cap bound to call_arg(${idx}), the SEP-41 \`transfer\` position. If \`${d.fn}\` carries the amount elsewhere this caps the wrong argument - pass amountArgIndex.`
131
+ )
132
+ }
133
+ children.push({
134
+ op: 'lte',
135
+ left: { kind: 'call_arg', index: idx },
136
+ right: { kind: 'literal_i128', value: d.maxAmount },
137
+ })
138
+ }
139
+
140
+ // A single conjunct is emitted bare. `and` with one child encodes to
141
+ // different bytes than the child alone, and the extra node buys nothing.
142
+ const predicate: PredicateNode =
143
+ children.length === 1 ? (children[0] as PredicateNode) : { op: 'and', children }
144
+ return { predicate, warnings }
145
+ }
146
+
147
+ function declareError(code: ToolError['code'], message: string): ToolError {
148
+ const err = new Error(message) as Error & {
149
+ code: ToolError['code']
150
+ severity: string
151
+ retryable: boolean
152
+ }
153
+ err.code = code
154
+ err.severity = 'error'
155
+ err.retryable = false
156
+ throw err
157
+ }
@@ -9,6 +9,11 @@ export {
9
9
  type ComposeResult,
10
10
  composeFromRecording,
11
11
  } from './compose-from-recording.ts'
12
+ export {
13
+ type DeclaredPredicate,
14
+ declarePredicate,
15
+ type PolicyDeclaration,
16
+ } from './declare.ts'
12
17
  export { type IntentFacts, lower } from './lower.ts'
13
18
  export {
14
19
  type DecideScopeOptions,