@crediolabs/policy-synth 1.1.1 → 1.3.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.
@@ -2554,6 +2554,19 @@ export declare const ObservedRuleSchema: z.ZodObject<{
2554
2554
  }>]>, "many">;
2555
2555
  policyAddresses: z.ZodArray<z.ZodString, "many">;
2556
2556
  predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
2557
+ /** Parameters of an OZ spend cap already on this rule. Reporting only:
2558
+ * whether a neighbour is capped is read from `policyAddresses`, so omitting
2559
+ * this never turns a capped rule into an uncapped one. */
2560
+ spendCap: z.ZodOptional<z.ZodObject<{
2561
+ amount: z.ZodString;
2562
+ periodLedgers: z.ZodNumber;
2563
+ }, "strip", z.ZodTypeAny, {
2564
+ amount: string;
2565
+ periodLedgers: number;
2566
+ }, {
2567
+ amount: string;
2568
+ periodLedgers: number;
2569
+ }>>;
2557
2570
  }, "strip", z.ZodTypeAny, {
2558
2571
  signers: ({
2559
2572
  address: string;
@@ -2574,6 +2587,10 @@ export declare const ObservedRuleSchema: z.ZodObject<{
2574
2587
  kind: "create_contract";
2575
2588
  wasmHash: string;
2576
2589
  };
2590
+ spendCap?: {
2591
+ amount: string;
2592
+ periodLedgers: number;
2593
+ } | undefined;
2577
2594
  predicate?: unknown;
2578
2595
  }, {
2579
2596
  signers: ({
@@ -2595,6 +2612,10 @@ export declare const ObservedRuleSchema: z.ZodObject<{
2595
2612
  kind: "create_contract";
2596
2613
  wasmHash: string;
2597
2614
  };
2615
+ spendCap?: {
2616
+ amount: string;
2617
+ periodLedgers: number;
2618
+ } | undefined;
2598
2619
  predicate?: unknown;
2599
2620
  }>;
2600
2621
  /** Pinned interpreter address (testnet).
@@ -2805,6 +2826,19 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2805
2826
  }>]>, "many">;
2806
2827
  policyAddresses: z.ZodArray<z.ZodString, "many">;
2807
2828
  predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
2829
+ /** Parameters of an OZ spend cap already on this rule. Reporting only:
2830
+ * whether a neighbour is capped is read from `policyAddresses`, so omitting
2831
+ * this never turns a capped rule into an uncapped one. */
2832
+ spendCap: z.ZodOptional<z.ZodObject<{
2833
+ amount: z.ZodString;
2834
+ periodLedgers: z.ZodNumber;
2835
+ }, "strip", z.ZodTypeAny, {
2836
+ amount: string;
2837
+ periodLedgers: number;
2838
+ }, {
2839
+ amount: string;
2840
+ periodLedgers: number;
2841
+ }>>;
2808
2842
  }, "strip", z.ZodTypeAny, {
2809
2843
  signers: ({
2810
2844
  address: string;
@@ -2825,6 +2859,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2825
2859
  kind: "create_contract";
2826
2860
  wasmHash: string;
2827
2861
  };
2862
+ spendCap?: {
2863
+ amount: string;
2864
+ periodLedgers: number;
2865
+ } | undefined;
2828
2866
  predicate?: unknown;
2829
2867
  }, {
2830
2868
  signers: ({
@@ -2846,6 +2884,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
2846
2884
  kind: "create_contract";
2847
2885
  wasmHash: string;
2848
2886
  };
2887
+ spendCap?: {
2888
+ amount: string;
2889
+ periodLedgers: number;
2890
+ } | undefined;
2849
2891
  predicate?: unknown;
2850
2892
  }>, "many">>;
2851
2893
  /** The smart account contract address (C...) that will receive the rule. */
@@ -3366,6 +3408,17 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3366
3408
  * knows. Supply it only to re-install over an existing rule, where the
3367
3409
  * interpreter wants `stored_nonce + 1`. */
3368
3410
  installNonce: z.ZodOptional<z.ZodNumber>;
3411
+ /** Absolute path to write the unsigned envelope to.
3412
+ *
3413
+ * The envelope runs to several thousand characters, and without this the
3414
+ * only route onto disk is the CALLER re-emitting it - through a model, a
3415
+ * shell argument, or both. That transport mangles it: observed in practice
3416
+ * as a file of the right length whose bytes no longer parse
3417
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
3418
+ * Writing it here takes the caller out of the transport entirely.
3419
+ *
3420
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
3421
+ outPath: z.ZodOptional<z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>>;
3369
3422
  /** Optional RPC URL override. Defaults to the pinned RPC for the
3370
3423
  * selected `network` (testnet by default, mainnet when
3371
3424
  * `network: 'mainnet'`); the override is refused unless
@@ -3411,6 +3464,18 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3411
3464
  * unaffected; only the case the synthesizer explicitly flagged is
3412
3465
  * refused. */
3413
3466
  allowUnboundedAmount: z.ZodOptional<z.ZodBoolean>;
3467
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
3468
+ *
3469
+ * An OZ account resolves a call against the rule the caller NAMES, so a
3470
+ * key on several rules gets the MAXIMUM authority over them, never the
3471
+ * intersection. A key that also sits on a rule with no policy is therefore
3472
+ * unconstrained: it names that rule and the new predicate never runs.
3473
+ *
3474
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
3475
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
3476
+ * this tool cannot decode stays advisory, because "cannot decode" is not
3477
+ * "proved unsafe" and refusing it would block installs on a guess. */
3478
+ allowAuthorityOverlap: z.ZodOptional<z.ZodBoolean>;
3414
3479
  /** Opt-in to pointing the rule's interpreter policy at any address
3415
3480
  * other than the pinned interpreter for the selected network.
3416
3481
  * Default-deny: a caller that controls the interpreter can permit
@@ -3448,6 +3513,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3448
3513
  kind: "create_contract";
3449
3514
  wasmHash: string;
3450
3515
  };
3516
+ spendCap?: {
3517
+ amount: string;
3518
+ periodLedgers: number;
3519
+ } | undefined;
3451
3520
  predicate?: unknown;
3452
3521
  }[] | undefined;
3453
3522
  rule?: z.objectOutputType<{
@@ -3549,9 +3618,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3549
3618
  validUntilLedger?: number | undefined;
3550
3619
  name?: string | undefined;
3551
3620
  } | undefined;
3621
+ outPath?: string | undefined;
3552
3622
  rpcUrl?: string | undefined;
3553
3623
  allowUnpinnedRpcUrl?: boolean | undefined;
3554
3624
  allowUnboundedAmount?: boolean | undefined;
3625
+ allowAuthorityOverlap?: boolean | undefined;
3555
3626
  allowUnpinnedInterpreter?: boolean | undefined;
3556
3627
  baseFee?: number | undefined;
3557
3628
  }, {
@@ -3583,6 +3654,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3583
3654
  kind: "create_contract";
3584
3655
  wasmHash: string;
3585
3656
  };
3657
+ spendCap?: {
3658
+ amount: string;
3659
+ periodLedgers: number;
3660
+ } | undefined;
3586
3661
  predicate?: unknown;
3587
3662
  }[] | undefined;
3588
3663
  rule?: z.objectInputType<{
@@ -3684,9 +3759,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3684
3759
  validUntilLedger?: number | undefined;
3685
3760
  name?: string | undefined;
3686
3761
  } | undefined;
3762
+ outPath?: string | undefined;
3687
3763
  rpcUrl?: string | undefined;
3688
3764
  allowUnpinnedRpcUrl?: boolean | undefined;
3689
3765
  allowUnboundedAmount?: boolean | undefined;
3766
+ allowAuthorityOverlap?: boolean | undefined;
3690
3767
  allowUnpinnedInterpreter?: boolean | undefined;
3691
3768
  baseFee?: number | undefined;
3692
3769
  }>, {
@@ -3718,6 +3795,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3718
3795
  kind: "create_contract";
3719
3796
  wasmHash: string;
3720
3797
  };
3798
+ spendCap?: {
3799
+ amount: string;
3800
+ periodLedgers: number;
3801
+ } | undefined;
3721
3802
  predicate?: unknown;
3722
3803
  }[] | undefined;
3723
3804
  rule?: z.objectOutputType<{
@@ -3819,9 +3900,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3819
3900
  validUntilLedger?: number | undefined;
3820
3901
  name?: string | undefined;
3821
3902
  } | undefined;
3903
+ outPath?: string | undefined;
3822
3904
  rpcUrl?: string | undefined;
3823
3905
  allowUnpinnedRpcUrl?: boolean | undefined;
3824
3906
  allowUnboundedAmount?: boolean | undefined;
3907
+ allowAuthorityOverlap?: boolean | undefined;
3825
3908
  allowUnpinnedInterpreter?: boolean | undefined;
3826
3909
  baseFee?: number | undefined;
3827
3910
  }, {
@@ -3853,6 +3936,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3853
3936
  kind: "create_contract";
3854
3937
  wasmHash: string;
3855
3938
  };
3939
+ spendCap?: {
3940
+ amount: string;
3941
+ periodLedgers: number;
3942
+ } | undefined;
3856
3943
  predicate?: unknown;
3857
3944
  }[] | undefined;
3858
3945
  rule?: z.objectInputType<{
@@ -3954,9 +4041,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3954
4041
  validUntilLedger?: number | undefined;
3955
4042
  name?: string | undefined;
3956
4043
  } | undefined;
4044
+ outPath?: string | undefined;
3957
4045
  rpcUrl?: string | undefined;
3958
4046
  allowUnpinnedRpcUrl?: boolean | undefined;
3959
4047
  allowUnboundedAmount?: boolean | undefined;
4048
+ allowAuthorityOverlap?: boolean | undefined;
3960
4049
  allowUnpinnedInterpreter?: boolean | undefined;
3961
4050
  baseFee?: number | undefined;
3962
4051
  }>, {
@@ -3988,6 +4077,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
3988
4077
  kind: "create_contract";
3989
4078
  wasmHash: string;
3990
4079
  };
4080
+ spendCap?: {
4081
+ amount: string;
4082
+ periodLedgers: number;
4083
+ } | undefined;
3991
4084
  predicate?: unknown;
3992
4085
  }[] | undefined;
3993
4086
  rule?: z.objectOutputType<{
@@ -4089,9 +4182,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4089
4182
  validUntilLedger?: number | undefined;
4090
4183
  name?: string | undefined;
4091
4184
  } | undefined;
4185
+ outPath?: string | undefined;
4092
4186
  rpcUrl?: string | undefined;
4093
4187
  allowUnpinnedRpcUrl?: boolean | undefined;
4094
4188
  allowUnboundedAmount?: boolean | undefined;
4189
+ allowAuthorityOverlap?: boolean | undefined;
4095
4190
  allowUnpinnedInterpreter?: boolean | undefined;
4096
4191
  baseFee?: number | undefined;
4097
4192
  }, {
@@ -4123,6 +4218,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4123
4218
  kind: "create_contract";
4124
4219
  wasmHash: string;
4125
4220
  };
4221
+ spendCap?: {
4222
+ amount: string;
4223
+ periodLedgers: number;
4224
+ } | undefined;
4126
4225
  predicate?: unknown;
4127
4226
  }[] | undefined;
4128
4227
  rule?: z.objectInputType<{
@@ -4224,9 +4323,11 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
4224
4323
  validUntilLedger?: number | undefined;
4225
4324
  name?: string | undefined;
4226
4325
  } | undefined;
4326
+ outPath?: string | undefined;
4227
4327
  rpcUrl?: string | undefined;
4228
4328
  allowUnpinnedRpcUrl?: boolean | undefined;
4229
4329
  allowUnboundedAmount?: boolean | undefined;
4330
+ allowAuthorityOverlap?: boolean | undefined;
4230
4331
  allowUnpinnedInterpreter?: boolean | undefined;
4231
4332
  baseFee?: number | undefined;
4232
4333
  }>;
@@ -338,6 +338,15 @@ export const ObservedRuleSchema = z.object({
338
338
  signers: z.array(SignerDraftSchema),
339
339
  policyAddresses: z.array(z.string()),
340
340
  predicate: PredicateNodeSchema.optional(),
341
+ /** Parameters of an OZ spend cap already on this rule. Reporting only:
342
+ * whether a neighbour is capped is read from `policyAddresses`, so omitting
343
+ * this never turns a capped rule into an uncapped one. */
344
+ spendCap: z
345
+ .object({
346
+ amount: z.string().regex(/^[0-9]+$/),
347
+ periodLedgers: z.number().int().positive().max(U32_MAX),
348
+ })
349
+ .optional(),
341
350
  });
342
351
  const ContextRuleDraftSchema = z
343
352
  .object({
@@ -623,6 +632,22 @@ export const InstallPolicyInputSchema = z
623
632
  * knows. Supply it only to re-install over an existing rule, where the
624
633
  * interpreter wants `stored_nonce + 1`. */
625
634
  installNonce: z.number().int().positive().optional(),
635
+ /** Absolute path to write the unsigned envelope to.
636
+ *
637
+ * The envelope runs to several thousand characters, and without this the
638
+ * only route onto disk is the CALLER re-emitting it - through a model, a
639
+ * shell argument, or both. That transport mangles it: observed in practice
640
+ * as a file of the right length whose bytes no longer parse
641
+ * ("xdr padding contains non-zero bytes"), and as silent truncation.
642
+ * Writing it here takes the caller out of the transport entirely.
643
+ *
644
+ * Opt-in: omitted, nothing is written and behaviour is unchanged. */
645
+ outPath: z
646
+ .string()
647
+ .min(1)
648
+ .refine((p) => p.startsWith('/'), 'outPath must be an absolute path')
649
+ .refine((p) => !p.includes('\0'), 'outPath must not contain a null byte')
650
+ .optional(),
626
651
  /** Optional RPC URL override. Defaults to the pinned RPC for the
627
652
  * selected `network` (testnet by default, mainnet when
628
653
  * `network: 'mainnet'`); the override is refused unless
@@ -666,6 +691,18 @@ export const InstallPolicyInputSchema = z
666
691
  * unaffected; only the case the synthesizer explicitly flagged is
667
692
  * refused. */
668
693
  allowUnboundedAmount: z.boolean().optional(),
694
+ /** Opt-in to installing a rule that the cross-rule scan proves cannot bind.
695
+ *
696
+ * An OZ account resolves a call against the rule the caller NAMES, so a
697
+ * key on several rules gets the MAXIMUM authority over them, never the
698
+ * intersection. A key that also sits on a rule with no policy is therefore
699
+ * unconstrained: it names that rule and the new predicate never runs.
700
+ *
701
+ * Default-deny, and only for the case the scan can PROVE - an unpoliced
702
+ * neighbour sharing signers and selectors. A neighbour carrying a policy
703
+ * this tool cannot decode stays advisory, because "cannot decode" is not
704
+ * "proved unsafe" and refusing it would block installs on a guess. */
705
+ allowAuthorityOverlap: z.boolean().optional(),
669
706
  /** Opt-in to pointing the rule's interpreter policy at any address
670
707
  * other than the pinned interpreter for the selected network.
671
708
  * Default-deny: a caller that controls the interpreter can permit
@@ -24,6 +24,24 @@ export type ContextType = {
24
24
  * - `unpoliced`: no policy at all. Whatever its context type allows, its
25
25
  * signers may do without constraint. */
26
26
  export type RuleClass = 'interpreter' | 'foreign' | 'unpoliced';
27
+ /** An OZ `spending_limit`'s parameters. `amount` is in the token's smallest
28
+ * unit; the period is a LEDGER count, not seconds. */
29
+ export interface SpendCap {
30
+ amount: string;
31
+ periodLedgers: number;
32
+ }
33
+ /** The policy contracts this tool can recognise by address. Supplying them lets
34
+ * the scan reason about a neighbour's SPEND CAP instead of treating any
35
+ * non-interpreter policy as opaque.
36
+ *
37
+ * Recognition is what licenses the strong conclusion. "This rule has no spend
38
+ * cap" is only sound when every policy on it is accounted for; a single
39
+ * unrecognised address could be somebody else's cap, so such a rule stays
40
+ * advisory. */
41
+ export interface KnownPolicies {
42
+ interpreter: string;
43
+ spendingLimit: string;
44
+ }
27
45
  export interface ObservedRule {
28
46
  id: number;
29
47
  contextType: ContextType;
@@ -33,6 +51,11 @@ export interface ObservedRule {
33
51
  /** Decoded predicate. Present only when the rule is policed by OUR
34
52
  * interpreter and the stored document was readable. */
35
53
  predicate?: PredicateNode;
54
+ /** The attached spend cap's parameters, when the reader could read them from
55
+ * the policy's own storage. Attachment is decided from `policyAddresses`, so
56
+ * this being absent does NOT mean the rule is uncapped - only that the
57
+ * numbers are unknown. */
58
+ spendCap?: SpendCap;
36
59
  }
37
60
  export interface IntendedInstall {
38
61
  /** Rule the predicate is being installed onto. A re-install onto the same
@@ -42,9 +65,15 @@ export interface IntendedInstall {
42
65
  contextType: ContextType;
43
66
  signers: SignerDraft[];
44
67
  predicate: PredicateNode;
68
+ /** The rolling cap being installed alongside the predicate, when one is.
69
+ * Its presence is what makes a neighbour's LACK of a cap a finding: without
70
+ * it there is no total for a neighbour to route around. */
71
+ spendCap?: SpendCap;
45
72
  }
46
73
  export type OverlapSeverity =
47
- /** A neighbouring rule imposes no constraint at all on the shared calls. */
74
+ /** A neighbouring rule imposes no constraint at all on the shared calls, or
75
+ * imposes no ROLLING TOTAL on calls the new rule caps. Either way the new
76
+ * rule's bound does not hold for a signer who can name this one. */
48
77
  'bypass'
49
78
  /** A neighbouring policy exists but what it permits cannot be read. */
50
79
  | 'unknown'
@@ -60,6 +89,14 @@ export interface AuthorityOverlap {
60
89
  sharedSigners: SignerDraft[];
61
90
  /** The selectors both rules can serve. Non-empty by construction. */
62
91
  sharedSelectors: Selector[];
92
+ /** This neighbour's own rolling cap, when it has one this tool could read.
93
+ * A spend cap is keyed by (account, RULE id), so two capped rules do not
94
+ * share a budget - a signer on both may spend the SUM. */
95
+ spendCap?: SpendCap;
96
+ /** True when the new rule installs a rolling total and this neighbour serves
97
+ * some of the same calls WITHOUT one, which voids the total rather than
98
+ * merely widening it. Only set when every policy here was recognised. */
99
+ capBypass?: true;
63
100
  advice: string;
64
101
  }
65
102
  /** Canonical key for signer equality. Mirrors OZ's `Signer` enum: a delegated
@@ -98,4 +135,7 @@ export declare function effectiveSelectors(rule: ObservedRule): Selector[];
98
135
  export declare function findAuthorityOverlaps(args: {
99
136
  intended: IntendedInstall;
100
137
  existing: ObservedRule[];
138
+ /** Omit to skip spend-cap reasoning entirely: every neighbour is then judged
139
+ * exactly as before, on its policies' presence rather than their meaning. */
140
+ knownPolicies?: KnownPolicies;
101
141
  }): AuthorityOverlap[];
@@ -200,14 +200,38 @@ function classifyRule(rule) {
200
200
  return 'unpoliced';
201
201
  return rule.predicate ? 'interpreter' : 'foreign';
202
202
  }
203
- function adviceFor(cls, ruleId) {
203
+ /** Is the OZ spend cap attached to this rule? Decided by ADDRESS, so it holds
204
+ * whether or not the cap's parameters could be read. */
205
+ function hasSpendCap(rule, known) {
206
+ return rule.policyAddresses.includes(known.spendingLimit);
207
+ }
208
+ /** Every policy on the rule is one this tool knows the semantics of, so
209
+ * "there is no spend cap here" is an observation rather than an assumption. */
210
+ function allPoliciesRecognised(rule, known) {
211
+ return rule.policyAddresses.every((addr) => addr === known.interpreter || addr === known.spendingLimit);
212
+ }
213
+ function capBypassAdvice(ruleId) {
214
+ return `the rolling total you are installing will not bind: rule ${ruleId} serves some of the same calls for a shared signer and carries NO spend cap, so that signer spends through it without one. A cap is stored per RULE, never per key. Remove the shared signer from rule ${ruleId}, put an equivalent cap on it, or narrow it so it no longer serves these calls.`;
215
+ }
216
+ /** How a signer's spend adds up across two capped rules. Same period or not,
217
+ * the budgets are separate; saying so with the numbers beats saying it in the
218
+ * abstract, which is what the caller has to reason about. */
219
+ function combinedCapNote(mine, theirs) {
220
+ if (mine.periodLedgers === theirs.periodLedgers) {
221
+ const total = (BigInt(mine.amount) + BigInt(theirs.amount)).toString();
222
+ return ` Its cap is ${theirs.amount} over the same ${theirs.periodLedgers}-ledger period as yours, and the two budgets are separate, so a shared signer may spend ${total} in total.`;
223
+ }
224
+ return ` Its cap is ${theirs.amount} over ${theirs.periodLedgers} ledgers against your ${mine.amount} over ${mine.periodLedgers}; the periods differ, so the two budgets neither share nor cancel and a shared signer draws on both.`;
225
+ }
226
+ function adviceFor(cls, ruleId, theirCap, myCap) {
227
+ const capNote = theirCap !== undefined && myCap !== undefined ? combinedCapNote(myCap, theirCap) : '';
204
228
  switch (cls) {
205
229
  case 'unpoliced':
206
230
  return `rule ${ruleId} has no policy attached, so a shared signer may make these calls with no constraint at all - the predicate you are installing will never run for them. Remove the shared signer from rule ${ruleId}, or attach a policy to it.`;
207
231
  case 'foreign':
208
- return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule.`;
232
+ return `rule ${ruleId} is policed by a contract this tool cannot decode, so its authority over these calls is unknown. Review it by hand before relying on the new rule.${capNote}`;
209
233
  case 'interpreter':
210
- return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other.`;
234
+ return `a shared signer may name rule ${ruleId} instead, so the new rule will not restrict these calls. To TIGHTEN, edit rule ${ruleId} itself rather than adding a second rule. To ADD a separate capability, keep both and expect neither to constrain the other.${capNote}`;
211
235
  }
212
236
  }
213
237
  /**
@@ -231,17 +255,42 @@ function findAuthorityOverlaps(args) {
231
255
  if (sharedSelectors.length === 0)
232
256
  continue;
233
257
  const ruleClass = classifyRule(rule);
258
+ // A rolling total is keyed by (account, rule id), so it constrains THIS
259
+ // rule and nothing else. If the new rule carries one and a neighbour serves
260
+ // the same calls without one, the signer names the neighbour and spends
261
+ // without limit - the total was never a bound on the key. Proven on testnet
262
+ // in `docs/audit/evidence/oz-two-rule-blend-cap.log`, where an uncapped
263
+ // sibling rule passed the very amount the capped rule refused.
264
+ //
265
+ // Sound only when every policy on the neighbour was recognised. One
266
+ // unrecognised address could be another spend cap, and refusing an install
267
+ // over a policy we cannot read would be a guess, not a proof.
268
+ // An unpoliced neighbour is excluded deliberately. It has no policies, so
269
+ // it passes the "everything recognised, no cap" test vacuously - but the
270
+ // finding there is not that a total leaks, it is that NOTHING constrains
271
+ // those calls. Reporting the narrower cause would send the reader looking
272
+ // for a spend cap when the rule needs a policy at all.
273
+ const capBypass = ruleClass !== 'unpoliced' &&
274
+ args.knownPolicies !== undefined &&
275
+ args.intended.spendCap !== undefined &&
276
+ allPoliciesRecognised(rule, args.knownPolicies) &&
277
+ !hasSpendCap(rule, args.knownPolicies);
278
+ const severity = ruleClass === 'unpoliced' || capBypass
279
+ ? 'bypass'
280
+ : ruleClass === 'foreign'
281
+ ? 'unknown'
282
+ : 'not-restricting';
234
283
  out.push({
235
284
  ruleId: rule.id,
236
285
  ruleClass,
237
- severity: ruleClass === 'unpoliced'
238
- ? 'bypass'
239
- : ruleClass === 'foreign'
240
- ? 'unknown'
241
- : 'not-restricting',
286
+ severity,
242
287
  sharedSigners: shared,
243
288
  sharedSelectors,
244
- advice: adviceFor(ruleClass, rule.id),
289
+ ...(rule.spendCap !== undefined ? { spendCap: rule.spendCap } : {}),
290
+ ...(capBypass ? { capBypass: true } : {}),
291
+ advice: capBypass
292
+ ? capBypassAdvice(rule.id)
293
+ : adviceFor(ruleClass, rule.id, rule.spendCap, args.intended.spendCap),
245
294
  });
246
295
  }
247
296
  return out;
@@ -1,6 +1,6 @@
1
1
  import { rpc, xdr } from '@stellar/stellar-sdk';
2
2
  import type { SignerDraft } from '../types.ts';
3
- import type { ContextType, ObservedRule } from './authority-overlap.ts';
3
+ import type { ContextType, ObservedRule, SpendCap } from './authority-overlap.ts';
4
4
  /** `storage.rs` - the third element of the persistent doc key tuple. */
5
5
  export declare const K_DOC = 1;
6
6
  /** Persistent-storage key for a rule's stored document:
@@ -19,6 +19,20 @@ export declare function decodeSigner(v: xdr.ScVal): SignerDraft | undefined;
19
19
  export declare function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined;
20
20
  /** The interpreter's `StoredDoc { predicate_bytes }`. */
21
21
  export declare function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined;
22
+ /** Persistent-storage key for a rule's spend-cap data:
23
+ * `SpendingLimitStorageKey::AccountContext(account, rule_id)`, which the host
24
+ * encodes as an enum variant - the symbol first, then the payload. */
25
+ export declare function spendCapKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal;
26
+ /** Ledger key for the OZ spend cap's own persistent entry. The policy exposes
27
+ * `get_spending_limit_data`, but that PANICS when nothing is installed, and a
28
+ * panic is indistinguishable from an RPC fault at the call site. Reading the
29
+ * entry lets "no cap here" come back as an absence instead. */
30
+ export declare function spendCapLedgerKey(spendingLimit: string, smartAccount: string, ruleId: number): xdr.LedgerKey;
31
+ /** `SpendingLimitData`, of which only the two installed parameters matter here.
32
+ * The running total and the history are deliberately ignored: they say what
33
+ * has been spent so far, which changes every call, while the scan is about
34
+ * what the rule PERMITS. */
35
+ export declare function decodeSpendCap(v: xdr.ScVal): SpendCap | undefined;
22
36
  /** The three reads the scan needs. Kept as an interface so the collection
23
37
  * below is testable without a network. */
24
38
  export interface AccountRuleReader {
@@ -29,6 +43,10 @@ export interface AccountRuleReader {
29
43
  /** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
30
44
  * Undefined when no document is stored for that rule. */
31
45
  getStoredDoc(interpreter: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
46
+ /** The OZ spend cap's persistent entry for this rule, read as a ledger entry.
47
+ * Optional so an existing reader keeps working: without it the scan simply
48
+ * reports no cap parameters, which is the same as it behaved before. */
49
+ getSpendCapData?(spendingLimit: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
32
50
  }
33
51
  /** How far the id scan will probe before giving up. OZ imposes no per-account
34
52
  * rule cap, so there is no exact bound to derive; this one is far above any
@@ -64,6 +82,10 @@ export declare function collectObservedRules(args: {
64
82
  reader: AccountRuleReader;
65
83
  smartAccount: string;
66
84
  interpreterAddress: string;
85
+ /** The pinned OZ spend cap. Supplying it fills in the PARAMETERS of a
86
+ * neighbour's cap; whether one is attached at all is decided from the
87
+ * rule's policy addresses and does not depend on this read succeeding. */
88
+ spendingLimitAddress?: string;
67
89
  maxRuleIdScan?: number;
68
90
  }): Promise<CollectedRules>;
69
91
  /**
@@ -28,6 +28,9 @@ exports.decodeContextType = decodeContextType;
28
28
  exports.decodeSigner = decodeSigner;
29
29
  exports.decodeContextRule = decodeContextRule;
30
30
  exports.decodeStoredPredicateBytes = decodeStoredPredicateBytes;
31
+ exports.spendCapKeyScVal = spendCapKeyScVal;
32
+ exports.spendCapLedgerKey = spendCapLedgerKey;
33
+ exports.decodeSpendCap = decodeSpendCap;
31
34
  exports.collectObservedRules = collectObservedRules;
32
35
  exports.accountRuleReaderFromServer = accountRuleReaderFromServer;
33
36
  const stellar_sdk_1 = require("@stellar/stellar-sdk");
@@ -158,6 +161,43 @@ function decodeStoredPredicateBytes(v) {
158
161
  return undefined;
159
162
  return field.bytes();
160
163
  }
164
+ /** Persistent-storage key for a rule's spend-cap data:
165
+ * `SpendingLimitStorageKey::AccountContext(account, rule_id)`, which the host
166
+ * encodes as an enum variant - the symbol first, then the payload. */
167
+ function spendCapKeyScVal(smartAccount, ruleId) {
168
+ return stellar_sdk_1.xdr.ScVal.scvVec([
169
+ stellar_sdk_1.xdr.ScVal.scvSymbol('AccountContext'),
170
+ new stellar_sdk_1.Address(smartAccount).toScVal(),
171
+ stellar_sdk_1.xdr.ScVal.scvU32(ruleId),
172
+ ]);
173
+ }
174
+ /** Ledger key for the OZ spend cap's own persistent entry. The policy exposes
175
+ * `get_spending_limit_data`, but that PANICS when nothing is installed, and a
176
+ * panic is indistinguishable from an RPC fault at the call site. Reading the
177
+ * entry lets "no cap here" come back as an absence instead. */
178
+ function spendCapLedgerKey(spendingLimit, smartAccount, ruleId) {
179
+ return stellar_sdk_1.xdr.LedgerKey.contractData(new stellar_sdk_1.xdr.LedgerKeyContractData({
180
+ contract: new stellar_sdk_1.Address(spendingLimit).toScAddress(),
181
+ key: spendCapKeyScVal(smartAccount, ruleId),
182
+ durability: stellar_sdk_1.xdr.ContractDataDurability.persistent(),
183
+ }));
184
+ }
185
+ /** `SpendingLimitData`, of which only the two installed parameters matter here.
186
+ * The running total and the history are deliberately ignored: they say what
187
+ * has been spent so far, which changes every call, while the scan is about
188
+ * what the rule PERMITS. */
189
+ function decodeSpendCap(v) {
190
+ const periodLedgers = u32Of(mapField(v, 'period_ledgers'));
191
+ const limit = mapField(v, 'spending_limit');
192
+ if (periodLedgers === undefined || !limit)
193
+ return undefined;
194
+ if (limit.switch() !== stellar_sdk_1.xdr.ScValType.scvI128())
195
+ return undefined;
196
+ const amount = (0, stellar_sdk_1.scValToNative)(limit);
197
+ if (typeof amount !== 'bigint')
198
+ return undefined;
199
+ return { amount: amount.toString(), periodLedgers };
200
+ }
161
201
  /** How far the id scan will probe before giving up. OZ imposes no per-account
162
202
  * rule cap, so there is no exact bound to derive; this one is far above any
163
203
  * realistic account and keeps a malformed `Count` from spinning forever. */
@@ -206,6 +246,16 @@ async function collectObservedRules(args) {
206
246
  unreadablePredicateRuleIds.push(rule.id);
207
247
  }
208
248
  }
249
+ const spendCapAddress = args.spendingLimitAddress;
250
+ if (spendCapAddress !== undefined && rule.policyAddresses.includes(spendCapAddress)) {
251
+ const data = await args.reader.getSpendCapData?.(spendCapAddress, args.smartAccount, rule.id);
252
+ const cap = data ? decodeSpendCap(data) : undefined;
253
+ // An unreadable cap is left absent rather than guessed at. The rule still
254
+ // counts as capped, because attachment came from its policy addresses,
255
+ // so failing this read weakens the REPORT and never the refusal.
256
+ if (cap)
257
+ rule.spendCap = cap;
258
+ }
209
259
  rules.push(rule);
210
260
  }
211
261
  return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count };
@@ -248,5 +298,13 @@ function accountRuleReaderFromServer(server, networkPassphrase) {
248
298
  return undefined;
249
299
  return entry.contractData().val();
250
300
  },
301
+ async getSpendCapData(spendingLimit, smartAccount, ruleId) {
302
+ const key = spendCapLedgerKey(spendingLimit, smartAccount, ruleId);
303
+ const res = await server.getLedgerEntries(key);
304
+ const entry = res.entries?.[0]?.val;
305
+ if (!entry || entry.switch() !== stellar_sdk_1.xdr.LedgerEntryType.contractData())
306
+ return undefined;
307
+ return entry.contractData().val();
308
+ },
251
309
  };
252
310
  }
@@ -38,6 +38,23 @@ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<
38
38
  predicateTree: PredicateNode | null;
39
39
  };
40
40
  }>;
41
+ /** The refusal message for an install the cross-rule scan proves cannot bind,
42
+ * or `undefined` when the install may proceed.
43
+ *
44
+ * Only `bypass` refuses, and it covers two proofs. Either the neighbouring
45
+ * rule carries NO policy, so a shared signer names it and the new predicate
46
+ * never runs; or the install carries a rolling total and a fully recognised
47
+ * neighbour serves the same calls without one, so the total is not a bound on
48
+ * the key. Both are proofs from data already in hand.
49
+ *
50
+ * `unknown` (a neighbour policed by a contract this tool cannot decode) stays
51
+ * advisory: it may well be tighter, and refusing on "cannot decode" would
52
+ * block installs on a guess. A `null` scan is NOT CHECKED, which is not
53
+ * evidence of a bypass and must not refuse on its own.
54
+ *
55
+ * Separated from the tool body so the decision can be tested without a
56
+ * network: the install it guards cannot be built without one. */
57
+ export declare function authorityBypassRefusal(scan: AuthorityOverlap[] | null, allowAuthorityOverlap: boolean | undefined): string | undefined;
41
58
  export declare function runInstallPolicy(raw: unknown): Promise<ToolResponse<BuildInstallPolicyResult & {
42
59
  authorityScan: AuthorityOverlap[] | null;
43
60
  }>>;