@crediolabs/policy-synth 0.1.12 → 0.1.13

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.
@@ -246,6 +246,332 @@ export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
246
246
  ])
247
247
  export type SynthesizePolicyInput = z.infer<typeof SynthesizePolicyInputSchema>
248
248
 
249
+ // ===== PredicateNode / PredicateLeaf =====
250
+ //
251
+ // Hand-written zod mirrors of the predicate grammar in types.ts:132-180.
252
+ // Recursive (`and`/`or`/`not` nest), so the schemas use the same
253
+ // `z.lazy` + explicit `z.ZodType<unknown>` annotation that ScValSchema
254
+ // already uses - without the annotation TS's circular type inference
255
+ // breaks the recursive reference. i128-shaped numerics (`num`, `den`)
256
+ // are decimal STRINGS, never JS numbers, matching the on-chain i128
257
+ // convention used everywhere else in this file. The grammar is the
258
+ // single source of truth (types.ts); a drift test in this package
259
+ // asserts the two stay in step.
260
+
261
+ /** PredicateLeaf mirrors the core `PredicateLeaf` union. Every variant
262
+ * is a `kind` discriminator; literals carry their primitive value inline.
263
+ * `literal_vec.elements` is recursive (a vec may contain literals that
264
+ * themselves nest) so the leaf schema is lazily self-referential. */
265
+ export const PredicateLeafSchema: z.ZodType<unknown> = z.lazy(() =>
266
+ z.union([
267
+ z.object({ kind: z.literal('call_contract') }),
268
+ z.object({ kind: z.literal('call_fn') }),
269
+ z.object({ kind: z.literal('call_arg'), index: z.number().int().nonnegative() }),
270
+ z.object({ kind: z.literal('call_arg_len'), index: z.number().int().nonnegative() }),
271
+ z.object({
272
+ kind: z.literal('call_arg_field'),
273
+ index: z.number().int().nonnegative(),
274
+ element: z.number().int().nonnegative(),
275
+ field: z.string(),
276
+ }),
277
+ // num/den are i128 on chain; JSON numbers lose precision past 2^53,
278
+ // so the wire is a decimal string. The contract refuses `den == 0` and
279
+ // `num <= 0` / `den <= 0` at install (types.ts:162-163 + the install
280
+ // gate); mirroring that bound here closes the gap where simulate /
281
+ // verify green-light a policy the install gate will reject. The
282
+ // positive-only regex is the cheapest unambiguous check.
283
+ z.object({
284
+ kind: z.literal('call_arg_scaled'),
285
+ index: z.number().int().nonnegative(),
286
+ num: z.string().regex(/^[1-9][0-9]*$/),
287
+ den: z.string().regex(/^[1-9][0-9]*$/),
288
+ }),
289
+ z.object({ kind: z.literal('amount'), token: z.string() }),
290
+ z.object({
291
+ kind: z.literal('window_spent'),
292
+ token: z.string(),
293
+ windowSeconds: z.number().int().positive(),
294
+ }),
295
+ z.object({ kind: z.literal('now') }),
296
+ z.object({ kind: z.literal('valid_until') }),
297
+ z.object({
298
+ kind: z.literal('invocation_count_in_window'),
299
+ windowSecs: z.number().int().positive(),
300
+ }),
301
+ z.object({ kind: z.literal('oracle_price'), asset: z.string() }),
302
+ z.object({
303
+ kind: z.literal('oracle_threshold'),
304
+ value: z.string().regex(/^-?[0-9]+$/),
305
+ decimals: z.number().int().nonnegative(),
306
+ }),
307
+ z.object({ kind: z.literal('literal_address'), value: z.string() }),
308
+ z.object({ kind: z.literal('literal_i128'), value: z.string().regex(/^-?[0-9]+$/) }),
309
+ z.object({ kind: z.literal('literal_symbol'), value: z.string() }),
310
+ // `literal_u32` is u32 on chain. A negative value would wrap, and a
311
+ // value above U32_MAX would be refused at the encode boundary
312
+ // anyway. Bound at the schema so the wire cannot carry a value the
313
+ // contract will reject.
314
+ z.object({
315
+ kind: z.literal('literal_u32'),
316
+ value: z.number().int().nonnegative().max(U32_MAX),
317
+ }),
318
+ z.object({ kind: z.literal('literal_u64'), value: z.string().regex(/^[0-9]+$/) }),
319
+ // `literal_bytes` is hex on chain. `Buffer.from(value, 'hex')` silently
320
+ // drops non-hex chars AND yields an empty buffer when the whole input
321
+ // is non-hex - so a user passing 'zzzz' would get a predicate that
322
+ // compares against empty bytes instead of being rejected. The
323
+ // strict even-length hex regex closes that gap at the boundary.
324
+ z.object({
325
+ kind: z.literal('literal_bytes'),
326
+ value: z
327
+ .string()
328
+ .regex(/^[0-9a-fA-F]*$/)
329
+ .refine((v) => v.length % 2 === 0, 'must be even-length hex'),
330
+ }),
331
+ // elements are themselves PredicateLeaf; lazy to break the cycle.
332
+ z.object({ kind: z.literal('literal_vec'), elements: z.array(PredicateLeafSchema) }),
333
+ ])
334
+ )
335
+
336
+ /** PredicateNode mirrors the core `PredicateNode` union. Recursive through
337
+ * `and`/`or`/`not`; the lazy + annotation pattern keeps the recursion
338
+ * type-safe. */
339
+ export const PredicateNodeSchema: z.ZodType<unknown> = z.lazy(() =>
340
+ z.union([
341
+ z.object({ op: z.literal('and'), children: z.array(PredicateNodeSchema) }),
342
+ z.object({ op: z.literal('or'), children: z.array(PredicateNodeSchema) }),
343
+ z.object({ op: z.literal('not'), child: PredicateNodeSchema }),
344
+ z.object({
345
+ op: z.literal('eq'),
346
+ left: PredicateLeafSchema,
347
+ right: PredicateLeafSchema,
348
+ }),
349
+ z.object({
350
+ op: z.enum(['lt', 'lte', 'gt', 'gte']),
351
+ left: PredicateLeafSchema,
352
+ right: PredicateLeafSchema,
353
+ }),
354
+ z.object({
355
+ op: z.literal('in'),
356
+ needle: PredicateLeafSchema,
357
+ haystack: z.array(PredicateLeafSchema),
358
+ }),
359
+ ])
360
+ )
361
+
362
+ // ===== simulate_policy / verify_policy =====
363
+ //
364
+ // The oracle fixture shape mirrors `SimulateOptions` / `VerifyOptions`
365
+ // in packages/policy-synth/src/verify/{simulate,verify}.ts - the same
366
+ // sorted set of error enum values is used by both engines. u32
367
+ // `validUntilLedger` is bounded at the boundary so a hand-crafted
368
+ // payload cannot leak a u32 overflow into the engine's permit context.
369
+
370
+ /** Oracle price fixture - mirror of the entry type in `SimulateOptions` /
371
+ * `VerifyOptions`. Discriminated by presence of `error` vs `price`. */
372
+ export const OraclePriceFixtureSchema = z.union([
373
+ z.object({
374
+ price: z.string().regex(/^[0-9]+$/),
375
+ timestampSeconds: z.number().int().nonnegative(),
376
+ }),
377
+ z.object({
378
+ error: z.enum(['stale', 'missing', 'deviation', 'paused', 'decimals', 'fingerprint']),
379
+ }),
380
+ ])
381
+
382
+ export const SimulatePolicyInputSchema = z.object({
383
+ // simulatePolicy accepts a null predicate (OZ-only policy); verifyPolicy
384
+ // does not. The asymmetry is mirrored at the schema boundary.
385
+ predicate: PredicateNodeSchema.nullable(),
386
+ permitTx: RecordedTransactionSchema,
387
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
388
+ oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
389
+ })
390
+ export type SimulatePolicyInput = z.infer<typeof SimulatePolicyInputSchema>
391
+
392
+ export const VerifyPolicyInputSchema = z.object({
393
+ // verifyPolicy requires a non-null predicate; the engine refuses
394
+ // `null` outright. The schema mirrors that contract.
395
+ predicate: PredicateNodeSchema,
396
+ permitTx: RecordedTransactionSchema,
397
+ validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
398
+ oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
399
+ })
400
+ export type VerifyPolicyInput = z.infer<typeof VerifyPolicyInputSchema>
401
+
402
+ // ===== install_policy / revoke_policy / get_interpreter_info =====
403
+ //
404
+ // The install/revoke pair drives the OZ smart-account admin flow. Both surface
405
+ // unsigned Soroban transaction XDRs (base64) and the tool body NEVER signs:
406
+ // the wallet signature IS the user-confirmation step. See `run/index.ts` for
407
+ // the rationale (no `action_id` two-call pair; the MCP server is stateless).
408
+ //
409
+ // `get_interpreter_info` is a read-only fingerprint lookup. It returns the
410
+ // pinned address + grammar version + wasm hash AND, when requested, runs an
411
+ // optional `grammar_version()` RPC call to check whether the deployed contract
412
+ // matches the pin. A mismatch is worth MORE than a fabricated audit field.
413
+ //
414
+ // All three input schemas live BELOW the MandateSpecSchemaForRule declaration
415
+ // so the const reference there is not in the temporal dead zone (no JS hoisting
416
+ // for `const`).
417
+
418
+ /** Minimal rule-draft schema for the install input. Mirrors ContextRuleDraft
419
+ * from the core (`types.ts`); we keep this hand-rolled + flat rather than
420
+ * importing the recursive discriminated union because the wire shape is
421
+ * fixed (the user supplies one rule, not a nested AST). Declared BEFORE
422
+ * InstallPolicyInputSchema below so the const reference there is not in
423
+ * the temporal dead zone (no JS hoisting for `const`). */
424
+ const MAX_SIGNERS_PER_RULE = 15
425
+ const MAX_POLICIES_PER_RULE = 5
426
+
427
+ const MandateSpecSchemaForRule = z
428
+ .object({
429
+ contextRuleType: z.discriminatedUnion('kind', [
430
+ z.object({ kind: z.literal('default') }),
431
+ z.object({ kind: z.literal('call_contract'), contract: z.string() }),
432
+ z.object({ kind: z.literal('create_contract'), wasmHash: z.string() }),
433
+ ]),
434
+ name: z.string().min(1),
435
+ validUntilLedger: z.number().int().positive().max(U32_MAX).nullable(),
436
+ signers: z
437
+ .array(
438
+ z.discriminatedUnion('kind', [
439
+ z.object({ kind: z.literal('delegated'), address: z.string() }),
440
+ z.object({
441
+ kind: z.literal('external'),
442
+ verifier: z.string(),
443
+ keyBytes: z.string(),
444
+ }),
445
+ ])
446
+ )
447
+ .max(MAX_SIGNERS_PER_RULE),
448
+ policies: z
449
+ .array(
450
+ z.discriminatedUnion('kind', [
451
+ z.object({
452
+ kind: z.literal('interpreter'),
453
+ interpreterAddress: z.string(),
454
+ predicateBlobBase64: z.string().min(1),
455
+ }),
456
+ z.object({
457
+ kind: z.literal('oz_builtin'),
458
+ primitive: z.object({
459
+ primitive: z.enum(['spending_limit', 'simple_threshold', 'weighted_threshold']),
460
+ params: z.record(z.unknown()),
461
+ }),
462
+ instanceAddress: z.string(),
463
+ }),
464
+ ])
465
+ )
466
+ .max(MAX_POLICIES_PER_RULE),
467
+ })
468
+ .passthrough()
469
+ .refine(
470
+ (v) => !(v.signers.length === 0 && v.policies.length === 0),
471
+ 'a rule with no signers and no policies is refused at install (NoSignersAndPolicies)'
472
+ )
473
+
474
+ // install/revoke/get_info schemas live HERE (after the helper) so the const
475
+ // reference resolves at module init. They are the new tools on top of the
476
+ // existing four.
477
+
478
+ /** Pinned interpreter address (testnet). Mirrors DEPLOYMENTS.md:156-157.
479
+ * Single source for the MCP layer; do not embed elsewhere. */
480
+ export const PINNED_INTERPRETER_TESTNET_ADDRESS =
481
+ 'CDR4NLV22STCXFGZPNKDQTEANWLF7LZ6AJLY6B7CLJXKHDZGYJWIOKGP'
482
+
483
+ /** Pinned interpreter wasm sha256 (hex). Mirrors DEPLOYMENTS.md:156-157. */
484
+ export const PINNED_INTERPRETER_WASM_SHA256 =
485
+ '6e6c13d93e197aa380303a42cd120f5ddb080dd36ef2a343ee1dbd04ca52a443'
486
+
487
+ /** The grammar version the interpreter enforces (matches SELF_VERSION in
488
+ * packages/policy-interpreter/src/version.rs). */
489
+ export const PINNED_INTERPRETER_GRAMMAR_VERSION = 1
490
+
491
+ /** Default Soroban RPC for the install / revoke / info tools. The recorder
492
+ * keeps its own copy in record/rpc.ts because it hands back a fetcher rather
493
+ * than a Server; the two are deliberately different surfaces, so this is
494
+ * pinned beside the addresses it is used with rather than shared. */
495
+ export const TESTNET_RPC_URL = 'https://soroban-testnet.stellar.org'
496
+
497
+ /** Soroban `valid_until` window (ledgers) added to the latest ledger when
498
+ * building the auth entry. ~25 minutes at 5s/ledger. */
499
+ export const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300
500
+
501
+ /** Stellar network passphrases. Pinned here so the XDR envelope uses the
502
+ * matching passphrase when the wallet signs (a mismatch yields invalid
503
+ * hashes). */
504
+ export const NETWORK_PASSPHRASES: Record<Network, string> = {
505
+ testnet: 'Test SDF Network ; September 2015',
506
+ mainnet: 'Public Global Stellar Network ; September 2015',
507
+ }
508
+
509
+ export const InstallPolicyInputSchema = z
510
+ .object({
511
+ /** The smart account contract address (C...) that will receive the rule. */
512
+ smartAccount: z.string(),
513
+ /** The signer that authorises the install (G... wallet). Used only for
514
+ * sequence number + auth nonce simulation; never persisted, never signed. */
515
+ sourceAccount: z.string(),
516
+ /** The proposed rule draft. Mirrors the core `ContextRuleDraft` shape. */
517
+ rule: MandateSpecSchemaForRule,
518
+ /** Per-rule install nonce; 1 for a fresh install. */
519
+ installNonce: z.number().int().positive(),
520
+ /** Optional RPC URL override. Defaults to the public testnet endpoint
521
+ * (`TESTNET_RPC_URL`); the override is refused unless
522
+ * `allowUnpinnedRpcUrl: true`, because the auth nonce and
523
+ * rootInvocation in the response come from whichever RPC answered. */
524
+ rpcUrl: z.string().url().optional(),
525
+ /** Opt-in to using a non-pinned RPC URL. Without this flag the tool
526
+ * refuses any `rpcUrl` other than `TESTNET_RPC_URL` because the
527
+ * caller's auth-digest binds to whatever the RPC returned. */
528
+ allowUnpinnedRpcUrl: z.boolean().optional(),
529
+ /** Opt-in to pointing the rule's interpreter policy at any address
530
+ * other than the pinned testnet interpreter. Default-deny: a caller
531
+ * that controls the interpreter can permit everything. */
532
+ allowUnpinnedInterpreter: z.boolean().optional(),
533
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
534
+ baseFee: z.number().int().positive().optional(),
535
+ })
536
+ .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
537
+ message: 'smartAccount and sourceAccount are required',
538
+ })
539
+ export type InstallPolicyInput = z.infer<typeof InstallPolicyInputSchema>
540
+
541
+ export const RevokePolicyInputSchema = z
542
+ .object({
543
+ /** The smart account contract address (C...). */
544
+ smartAccount: z.string(),
545
+ /** The wallet that will sign the removal. The ACCOUNT decides whether it
546
+ * accepts that signer; this schema does not assert a rule it cannot
547
+ * verify, since the account's source is not in this repo. Proven on
548
+ * testnet: the account's deployer can revoke. */
549
+ sourceAccount: z.string(),
550
+ /** The rule id to remove from the smart account + interpreter. */
551
+ ruleId: z.number().int().nonnegative(),
552
+ /** Optional RPC URL override; same pin as install. */
553
+ rpcUrl: z.string().url().optional(),
554
+ /** Opt-in to using a non-pinned RPC URL. Same semantics as install. */
555
+ allowUnpinnedRpcUrl: z.boolean().optional(),
556
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
557
+ baseFee: z.number().int().positive().optional(),
558
+ })
559
+ .refine((v) => Boolean(v.smartAccount) && Boolean(v.sourceAccount), {
560
+ message: 'smartAccount and sourceAccount are required',
561
+ })
562
+ export type RevokePolicyInput = z.infer<typeof RevokePolicyInputSchema>
563
+
564
+ export const GetInterpreterInfoInputSchema = z.object({
565
+ /** Network to query. The pin differs per network; defaults to testnet. */
566
+ network: NetworkSchema.optional(),
567
+ /** When true, perform an optional live `grammar_version()` RPC call to
568
+ * verify the deployed contract matches the pin. */
569
+ verifyLive: z.boolean().optional(),
570
+ /** Optional RPC URL override. */
571
+ rpcUrl: z.string().url().optional(),
572
+ })
573
+ export type GetInterpreterInfoInput = z.infer<typeof GetInterpreterInfoInputSchema>
574
+
249
575
  // ===== Error envelope (canonical) =====
250
576
  //
251
577
  // Mirrors ToolError from packages/policy-synth/src/errors.ts. We use a