@crediolabs/policy-synth 1.2.0 → 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.
- package/dist/install/authority-overlap.d.ts +41 -1
- package/dist/install/authority-overlap.js +58 -9
- package/dist/install/read-account-rules.d.ts +23 -1
- package/dist/install/read-account-rules.js +56 -1
- package/dist/run/index.d.ts +6 -3
- package/dist/run/index.js +29 -4
- package/dist/run/schemas.d.ts +66 -0
- package/dist/run/schemas.js +9 -0
- package/dist-cjs/install/authority-overlap.d.ts +41 -1
- package/dist-cjs/install/authority-overlap.js +58 -9
- package/dist-cjs/install/read-account-rules.d.ts +23 -1
- package/dist-cjs/install/read-account-rules.js +58 -0
- package/dist-cjs/run/index.d.ts +6 -3
- package/dist-cjs/run/index.js +29 -4
- package/dist-cjs/run/schemas.d.ts +66 -0
- package/dist-cjs/run/schemas.js +9 -0
- package/package.json +1 -1
- package/src/install/authority-overlap.ts +112 -11
- package/src/install/read-account-rules.ts +74 -1
- package/src/run/index.ts +29 -4
- package/src/run/schemas.ts +9 -0
|
@@ -200,14 +200,38 @@ function classifyRule(rule) {
|
|
|
200
200
|
return 'unpoliced';
|
|
201
201
|
return rule.predicate ? 'interpreter' : 'foreign';
|
|
202
202
|
}
|
|
203
|
-
|
|
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
|
|
238
|
-
? 'bypass'
|
|
239
|
-
: ruleClass === 'foreign'
|
|
240
|
-
? 'unknown'
|
|
241
|
-
: 'not-restricting',
|
|
286
|
+
severity,
|
|
242
287
|
sharedSigners: shared,
|
|
243
288
|
sharedSelectors,
|
|
244
|
-
|
|
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
|
}
|
package/dist-cjs/run/index.d.ts
CHANGED
|
@@ -41,9 +41,12 @@ export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<
|
|
|
41
41
|
/** The refusal message for an install the cross-rule scan proves cannot bind,
|
|
42
42
|
* or `undefined` when the install may proceed.
|
|
43
43
|
*
|
|
44
|
-
* Only `bypass` refuses
|
|
45
|
-
* policy, so a shared signer names it and the new predicate
|
|
46
|
-
*
|
|
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
|
+
*
|
|
47
50
|
* `unknown` (a neighbour policed by a contract this tool cannot decode) stays
|
|
48
51
|
* advisory: it may well be tighter, and refusing on "cannot decode" would
|
|
49
52
|
* block installs on a guess. A `null` scan is NOT CHECKED, which is not
|
package/dist-cjs/run/index.js
CHANGED
|
@@ -169,9 +169,12 @@ async function runSynthesizePolicy(raw) {
|
|
|
169
169
|
/** The refusal message for an install the cross-rule scan proves cannot bind,
|
|
170
170
|
* or `undefined` when the install may proceed.
|
|
171
171
|
*
|
|
172
|
-
* Only `bypass` refuses
|
|
173
|
-
* policy, so a shared signer names it and the new predicate
|
|
174
|
-
*
|
|
172
|
+
* Only `bypass` refuses, and it covers two proofs. Either the neighbouring
|
|
173
|
+
* rule carries NO policy, so a shared signer names it and the new predicate
|
|
174
|
+
* never runs; or the install carries a rolling total and a fully recognised
|
|
175
|
+
* neighbour serves the same calls without one, so the total is not a bound on
|
|
176
|
+
* the key. Both are proofs from data already in hand.
|
|
177
|
+
*
|
|
175
178
|
* `unknown` (a neighbour policed by a contract this tool cannot decode) stays
|
|
176
179
|
* advisory: it may well be tighter, and refusing on "cannot decode" would
|
|
177
180
|
* block installs on a guess. A `null` scan is NOT CHECKED, which is not
|
|
@@ -186,7 +189,12 @@ function authorityBypassRefusal(scan, allowAuthorityOverlap) {
|
|
|
186
189
|
if (proven.length === 0)
|
|
187
190
|
return undefined;
|
|
188
191
|
const ids = proven.map((o) => o.ruleId).join(', ');
|
|
189
|
-
|
|
192
|
+
// A cap bypass leaves the predicate working, so "constrains nothing" would
|
|
193
|
+
// overstate it and send the caller looking for the wrong defect.
|
|
194
|
+
const consequence = proven.every((o) => o.capBypass === true)
|
|
195
|
+
? 'This rule would install cleanly and its rolling total would not hold'
|
|
196
|
+
: 'This rule would install cleanly and constrain nothing';
|
|
197
|
+
return `install_policy: ${proven[0]?.advice ?? ''} ${consequence}, so it is refused (rule ${ids}); set \`allowAuthorityOverlap: true\` to install anyway.`;
|
|
190
198
|
}
|
|
191
199
|
async function runInstallPolicy(raw) {
|
|
192
200
|
const parsed = schemas_ts_1.InstallPolicyInputSchema.safeParse(raw);
|
|
@@ -387,8 +395,24 @@ async function runInstallPolicy(raw) {
|
|
|
387
395
|
contextType: rule.contextRuleType,
|
|
388
396
|
signers: rule.signers,
|
|
389
397
|
predicate: (0, decode_ts_1.decodePredicate)(encodedPredicate),
|
|
398
|
+
...(input.spendingLimit !== undefined
|
|
399
|
+
? {
|
|
400
|
+
spendCap: {
|
|
401
|
+
amount: input.spendingLimit.amount,
|
|
402
|
+
periodLedgers: input.spendingLimit.periodLedgers,
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
: {}),
|
|
390
406
|
},
|
|
391
407
|
existing: observed,
|
|
408
|
+
// Both addresses are pinned, so a neighbour's policies can be
|
|
409
|
+
// named rather than merely counted - which is what lets the scan
|
|
410
|
+
// say a rule has no spend cap instead of that it has something
|
|
411
|
+
// unreadable.
|
|
412
|
+
knownPolicies: {
|
|
413
|
+
interpreter: expectedInterpreter,
|
|
414
|
+
spendingLimit: schemas_ts_1.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
415
|
+
},
|
|
392
416
|
});
|
|
393
417
|
// A `bypass` overlap is not a warning, it is a proof that this rule cannot
|
|
394
418
|
// bind the key it names: the neighbour carries NO policy, so the signer
|
|
@@ -813,6 +837,7 @@ async function resolveExistingRules(input, network, interpreterAddress) {
|
|
|
813
837
|
reader: (0, read_account_rules_ts_1.accountRuleReaderFromServer)(server, schemas_ts_1.NETWORK_PASSPHRASES[network]),
|
|
814
838
|
smartAccount: input.smartAccount,
|
|
815
839
|
interpreterAddress,
|
|
840
|
+
spendingLimitAddress: schemas_ts_1.PINNED_OZ_POLICY_ADDRESS_BY_NETWORK[network].spending_limit,
|
|
816
841
|
});
|
|
817
842
|
if (collected.incomplete)
|
|
818
843
|
return null;
|
|
@@ -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. */
|
|
@@ -3471,6 +3513,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3471
3513
|
kind: "create_contract";
|
|
3472
3514
|
wasmHash: string;
|
|
3473
3515
|
};
|
|
3516
|
+
spendCap?: {
|
|
3517
|
+
amount: string;
|
|
3518
|
+
periodLedgers: number;
|
|
3519
|
+
} | undefined;
|
|
3474
3520
|
predicate?: unknown;
|
|
3475
3521
|
}[] | undefined;
|
|
3476
3522
|
rule?: z.objectOutputType<{
|
|
@@ -3608,6 +3654,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3608
3654
|
kind: "create_contract";
|
|
3609
3655
|
wasmHash: string;
|
|
3610
3656
|
};
|
|
3657
|
+
spendCap?: {
|
|
3658
|
+
amount: string;
|
|
3659
|
+
periodLedgers: number;
|
|
3660
|
+
} | undefined;
|
|
3611
3661
|
predicate?: unknown;
|
|
3612
3662
|
}[] | undefined;
|
|
3613
3663
|
rule?: z.objectInputType<{
|
|
@@ -3745,6 +3795,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3745
3795
|
kind: "create_contract";
|
|
3746
3796
|
wasmHash: string;
|
|
3747
3797
|
};
|
|
3798
|
+
spendCap?: {
|
|
3799
|
+
amount: string;
|
|
3800
|
+
periodLedgers: number;
|
|
3801
|
+
} | undefined;
|
|
3748
3802
|
predicate?: unknown;
|
|
3749
3803
|
}[] | undefined;
|
|
3750
3804
|
rule?: z.objectOutputType<{
|
|
@@ -3882,6 +3936,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
3882
3936
|
kind: "create_contract";
|
|
3883
3937
|
wasmHash: string;
|
|
3884
3938
|
};
|
|
3939
|
+
spendCap?: {
|
|
3940
|
+
amount: string;
|
|
3941
|
+
periodLedgers: number;
|
|
3942
|
+
} | undefined;
|
|
3885
3943
|
predicate?: unknown;
|
|
3886
3944
|
}[] | undefined;
|
|
3887
3945
|
rule?: z.objectInputType<{
|
|
@@ -4019,6 +4077,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
4019
4077
|
kind: "create_contract";
|
|
4020
4078
|
wasmHash: string;
|
|
4021
4079
|
};
|
|
4080
|
+
spendCap?: {
|
|
4081
|
+
amount: string;
|
|
4082
|
+
periodLedgers: number;
|
|
4083
|
+
} | undefined;
|
|
4022
4084
|
predicate?: unknown;
|
|
4023
4085
|
}[] | undefined;
|
|
4024
4086
|
rule?: z.objectOutputType<{
|
|
@@ -4156,6 +4218,10 @@ export declare const InstallPolicyInputSchema: z.ZodEffects<z.ZodEffects<z.ZodOb
|
|
|
4156
4218
|
kind: "create_contract";
|
|
4157
4219
|
wasmHash: string;
|
|
4158
4220
|
};
|
|
4221
|
+
spendCap?: {
|
|
4222
|
+
amount: string;
|
|
4223
|
+
periodLedgers: number;
|
|
4224
|
+
} | undefined;
|
|
4159
4225
|
predicate?: unknown;
|
|
4160
4226
|
}[] | undefined;
|
|
4161
4227
|
rule?: z.objectInputType<{
|
package/dist-cjs/run/schemas.js
CHANGED
|
@@ -341,6 +341,15 @@ exports.ObservedRuleSchema = zod_1.z.object({
|
|
|
341
341
|
signers: zod_1.z.array(SignerDraftSchema),
|
|
342
342
|
policyAddresses: zod_1.z.array(zod_1.z.string()),
|
|
343
343
|
predicate: exports.PredicateNodeSchema.optional(),
|
|
344
|
+
/** Parameters of an OZ spend cap already on this rule. Reporting only:
|
|
345
|
+
* whether a neighbour is capped is read from `policyAddresses`, so omitting
|
|
346
|
+
* this never turns a capped rule into an uncapped one. */
|
|
347
|
+
spendCap: zod_1.z
|
|
348
|
+
.object({
|
|
349
|
+
amount: zod_1.z.string().regex(/^[0-9]+$/),
|
|
350
|
+
periodLedgers: zod_1.z.number().int().positive().max(U32_MAX),
|
|
351
|
+
})
|
|
352
|
+
.optional(),
|
|
344
353
|
});
|
|
345
354
|
const ContextRuleDraftSchema = zod_1.z
|
|
346
355
|
.object({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-synth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
|
|
6
6
|
"type": "module",
|
|
@@ -50,6 +50,26 @@ export type ContextType =
|
|
|
50
50
|
* signers may do without constraint. */
|
|
51
51
|
export type RuleClass = 'interpreter' | 'foreign' | 'unpoliced'
|
|
52
52
|
|
|
53
|
+
/** An OZ `spending_limit`'s parameters. `amount` is in the token's smallest
|
|
54
|
+
* unit; the period is a LEDGER count, not seconds. */
|
|
55
|
+
export interface SpendCap {
|
|
56
|
+
amount: string
|
|
57
|
+
periodLedgers: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The policy contracts this tool can recognise by address. Supplying them lets
|
|
61
|
+
* the scan reason about a neighbour's SPEND CAP instead of treating any
|
|
62
|
+
* non-interpreter policy as opaque.
|
|
63
|
+
*
|
|
64
|
+
* Recognition is what licenses the strong conclusion. "This rule has no spend
|
|
65
|
+
* cap" is only sound when every policy on it is accounted for; a single
|
|
66
|
+
* unrecognised address could be somebody else's cap, so such a rule stays
|
|
67
|
+
* advisory. */
|
|
68
|
+
export interface KnownPolicies {
|
|
69
|
+
interpreter: string
|
|
70
|
+
spendingLimit: string
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
export interface ObservedRule {
|
|
54
74
|
id: number
|
|
55
75
|
contextType: ContextType
|
|
@@ -59,6 +79,11 @@ export interface ObservedRule {
|
|
|
59
79
|
/** Decoded predicate. Present only when the rule is policed by OUR
|
|
60
80
|
* interpreter and the stored document was readable. */
|
|
61
81
|
predicate?: PredicateNode
|
|
82
|
+
/** The attached spend cap's parameters, when the reader could read them from
|
|
83
|
+
* the policy's own storage. Attachment is decided from `policyAddresses`, so
|
|
84
|
+
* this being absent does NOT mean the rule is uncapped - only that the
|
|
85
|
+
* numbers are unknown. */
|
|
86
|
+
spendCap?: SpendCap
|
|
62
87
|
}
|
|
63
88
|
|
|
64
89
|
export interface IntendedInstall {
|
|
@@ -69,10 +94,16 @@ export interface IntendedInstall {
|
|
|
69
94
|
contextType: ContextType
|
|
70
95
|
signers: SignerDraft[]
|
|
71
96
|
predicate: PredicateNode
|
|
97
|
+
/** The rolling cap being installed alongside the predicate, when one is.
|
|
98
|
+
* Its presence is what makes a neighbour's LACK of a cap a finding: without
|
|
99
|
+
* it there is no total for a neighbour to route around. */
|
|
100
|
+
spendCap?: SpendCap
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
export type OverlapSeverity =
|
|
75
|
-
/** A neighbouring rule imposes no constraint at all on the shared calls
|
|
104
|
+
/** A neighbouring rule imposes no constraint at all on the shared calls, or
|
|
105
|
+
* imposes no ROLLING TOTAL on calls the new rule caps. Either way the new
|
|
106
|
+
* rule's bound does not hold for a signer who can name this one. */
|
|
76
107
|
| 'bypass'
|
|
77
108
|
/** A neighbouring policy exists but what it permits cannot be read. */
|
|
78
109
|
| 'unknown'
|
|
@@ -89,6 +120,14 @@ export interface AuthorityOverlap {
|
|
|
89
120
|
sharedSigners: SignerDraft[]
|
|
90
121
|
/** The selectors both rules can serve. Non-empty by construction. */
|
|
91
122
|
sharedSelectors: Selector[]
|
|
123
|
+
/** This neighbour's own rolling cap, when it has one this tool could read.
|
|
124
|
+
* A spend cap is keyed by (account, RULE id), so two capped rules do not
|
|
125
|
+
* share a budget - a signer on both may spend the SUM. */
|
|
126
|
+
spendCap?: SpendCap
|
|
127
|
+
/** True when the new rule installs a rolling total and this neighbour serves
|
|
128
|
+
* some of the same calls WITHOUT one, which voids the total rather than
|
|
129
|
+
* merely widening it. Only set when every policy here was recognised. */
|
|
130
|
+
capBypass?: true
|
|
92
131
|
advice: string
|
|
93
132
|
}
|
|
94
133
|
|
|
@@ -265,14 +304,45 @@ function classifyRule(rule: ObservedRule): RuleClass {
|
|
|
265
304
|
return rule.predicate ? 'interpreter' : 'foreign'
|
|
266
305
|
}
|
|
267
306
|
|
|
268
|
-
|
|
307
|
+
/** Is the OZ spend cap attached to this rule? Decided by ADDRESS, so it holds
|
|
308
|
+
* whether or not the cap's parameters could be read. */
|
|
309
|
+
function hasSpendCap(rule: ObservedRule, known: KnownPolicies): boolean {
|
|
310
|
+
return rule.policyAddresses.includes(known.spendingLimit)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Every policy on the rule is one this tool knows the semantics of, so
|
|
314
|
+
* "there is no spend cap here" is an observation rather than an assumption. */
|
|
315
|
+
function allPoliciesRecognised(rule: ObservedRule, known: KnownPolicies): boolean {
|
|
316
|
+
return rule.policyAddresses.every(
|
|
317
|
+
(addr) => addr === known.interpreter || addr === known.spendingLimit
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function capBypassAdvice(ruleId: number): string {
|
|
322
|
+
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.`
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** How a signer's spend adds up across two capped rules. Same period or not,
|
|
326
|
+
* the budgets are separate; saying so with the numbers beats saying it in the
|
|
327
|
+
* abstract, which is what the caller has to reason about. */
|
|
328
|
+
function combinedCapNote(mine: SpendCap, theirs: SpendCap): string {
|
|
329
|
+
if (mine.periodLedgers === theirs.periodLedgers) {
|
|
330
|
+
const total = (BigInt(mine.amount) + BigInt(theirs.amount)).toString()
|
|
331
|
+
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.`
|
|
332
|
+
}
|
|
333
|
+
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.`
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function adviceFor(cls: RuleClass, ruleId: number, theirCap?: SpendCap, myCap?: SpendCap): string {
|
|
337
|
+
const capNote =
|
|
338
|
+
theirCap !== undefined && myCap !== undefined ? combinedCapNote(myCap, theirCap) : ''
|
|
269
339
|
switch (cls) {
|
|
270
340
|
case 'unpoliced':
|
|
271
341
|
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.`
|
|
272
342
|
case 'foreign':
|
|
273
|
-
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
|
|
343
|
+
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}`
|
|
274
344
|
case 'interpreter':
|
|
275
|
-
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
|
|
345
|
+
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}`
|
|
276
346
|
}
|
|
277
347
|
}
|
|
278
348
|
|
|
@@ -287,6 +357,9 @@ function adviceFor(cls: RuleClass, ruleId: number): string {
|
|
|
287
357
|
export function findAuthorityOverlaps(args: {
|
|
288
358
|
intended: IntendedInstall
|
|
289
359
|
existing: ObservedRule[]
|
|
360
|
+
/** Omit to skip spend-cap reasoning entirely: every neighbour is then judged
|
|
361
|
+
* exactly as before, on its policies' presence rather than their meaning. */
|
|
362
|
+
knownPolicies?: KnownPolicies
|
|
290
363
|
}): AuthorityOverlap[] {
|
|
291
364
|
const intendedSelectors = intersectSelectors(
|
|
292
365
|
selectorsForContextType(args.intended.contextType),
|
|
@@ -304,18 +377,46 @@ export function findAuthorityOverlaps(args: {
|
|
|
304
377
|
if (sharedSelectors.length === 0) continue
|
|
305
378
|
|
|
306
379
|
const ruleClass = classifyRule(rule)
|
|
380
|
+
// A rolling total is keyed by (account, rule id), so it constrains THIS
|
|
381
|
+
// rule and nothing else. If the new rule carries one and a neighbour serves
|
|
382
|
+
// the same calls without one, the signer names the neighbour and spends
|
|
383
|
+
// without limit - the total was never a bound on the key. Proven on testnet
|
|
384
|
+
// in `docs/audit/evidence/oz-two-rule-blend-cap.log`, where an uncapped
|
|
385
|
+
// sibling rule passed the very amount the capped rule refused.
|
|
386
|
+
//
|
|
387
|
+
// Sound only when every policy on the neighbour was recognised. One
|
|
388
|
+
// unrecognised address could be another spend cap, and refusing an install
|
|
389
|
+
// over a policy we cannot read would be a guess, not a proof.
|
|
390
|
+
// An unpoliced neighbour is excluded deliberately. It has no policies, so
|
|
391
|
+
// it passes the "everything recognised, no cap" test vacuously - but the
|
|
392
|
+
// finding there is not that a total leaks, it is that NOTHING constrains
|
|
393
|
+
// those calls. Reporting the narrower cause would send the reader looking
|
|
394
|
+
// for a spend cap when the rule needs a policy at all.
|
|
395
|
+
const capBypass =
|
|
396
|
+
ruleClass !== 'unpoliced' &&
|
|
397
|
+
args.knownPolicies !== undefined &&
|
|
398
|
+
args.intended.spendCap !== undefined &&
|
|
399
|
+
allPoliciesRecognised(rule, args.knownPolicies) &&
|
|
400
|
+
!hasSpendCap(rule, args.knownPolicies)
|
|
401
|
+
|
|
402
|
+
const severity: OverlapSeverity =
|
|
403
|
+
ruleClass === 'unpoliced' || capBypass
|
|
404
|
+
? 'bypass'
|
|
405
|
+
: ruleClass === 'foreign'
|
|
406
|
+
? 'unknown'
|
|
407
|
+
: 'not-restricting'
|
|
408
|
+
|
|
307
409
|
out.push({
|
|
308
410
|
ruleId: rule.id,
|
|
309
411
|
ruleClass,
|
|
310
|
-
severity
|
|
311
|
-
ruleClass === 'unpoliced'
|
|
312
|
-
? 'bypass'
|
|
313
|
-
: ruleClass === 'foreign'
|
|
314
|
-
? 'unknown'
|
|
315
|
-
: 'not-restricting',
|
|
412
|
+
severity,
|
|
316
413
|
sharedSigners: shared,
|
|
317
414
|
sharedSelectors,
|
|
318
|
-
|
|
415
|
+
...(rule.spendCap !== undefined ? { spendCap: rule.spendCap } : {}),
|
|
416
|
+
...(capBypass ? { capBypass: true as const } : {}),
|
|
417
|
+
advice: capBypass
|
|
418
|
+
? capBypassAdvice(rule.id)
|
|
419
|
+
: adviceFor(ruleClass, rule.id, rule.spendCap, args.intended.spendCap),
|
|
319
420
|
})
|
|
320
421
|
}
|
|
321
422
|
|