@crediolabs/policy-synth 0.3.1 → 0.5.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/adapters/interpreter/adapter.d.ts +2 -2
- package/dist/adapters/interpreter/adapter.js +11 -3
- package/dist/errors.d.ts +6 -1
- package/dist/install/authority-overlap.d.ts +101 -0
- package/dist/install/authority-overlap.js +239 -0
- package/dist/install/build-add-context-rule.d.ts +1 -1
- package/dist/install/index.d.ts +1 -0
- package/dist/install/index.js +4 -0
- package/dist/install/read-account-rules.d.ts +79 -0
- package/dist/install/read-account-rules.js +241 -0
- package/dist/predicate/decode.js +22 -1
- package/dist/predicate/encode.js +52 -5
- package/dist/predicate/from-json.js +14 -1
- package/dist/record/index.d.ts +10 -0
- package/dist/record/index.js +32 -1
- package/dist/record/rpc.d.ts +4 -0
- package/dist/record/rpc.js +4 -1
- package/dist/registry/identify.d.ts +10 -1
- package/dist/registry/identify.js +4 -1
- package/dist/registry/on-chain-spec.d.ts +37 -0
- package/dist/registry/on-chain-spec.js +152 -0
- package/dist/review-card/builder.js +40 -0
- package/dist/review-card/cross-check.js +34 -0
- package/dist/review-card/render-leaf.d.ts +1 -1
- package/dist/review-card/render-leaf.js +7 -0
- package/dist/run/index.d.ts +25 -4
- package/dist/run/index.js +108 -4
- package/dist/run/schemas.d.ts +350 -6
- package/dist/run/schemas.js +95 -6
- package/dist/simulate/deny-cases.js +11 -0
- package/dist/simulate/evaluate.js +86 -5
- package/dist/synth/declare.d.ts +43 -0
- package/dist/synth/declare.js +127 -0
- package/dist/synth/index.d.ts +1 -0
- package/dist/synth/index.js +1 -0
- package/dist/synth/synthesize-from-recording.js +1 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.js +1 -1
- package/dist-cjs/adapters/interpreter/adapter.d.ts +2 -2
- package/dist-cjs/adapters/interpreter/adapter.js +11 -3
- package/dist-cjs/errors.d.ts +6 -1
- package/dist-cjs/install/authority-overlap.d.ts +101 -0
- package/dist-cjs/install/authority-overlap.js +248 -0
- package/dist-cjs/install/build-add-context-rule.d.ts +1 -1
- package/dist-cjs/install/index.d.ts +1 -0
- package/dist-cjs/install/index.js +13 -2
- package/dist-cjs/install/read-account-rules.d.ts +79 -0
- package/dist-cjs/install/read-account-rules.js +252 -0
- package/dist-cjs/predicate/decode.js +22 -1
- package/dist-cjs/predicate/encode.js +52 -5
- package/dist-cjs/predicate/from-json.js +14 -1
- package/dist-cjs/record/index.d.ts +10 -0
- package/dist-cjs/record/index.js +31 -0
- package/dist-cjs/record/rpc.d.ts +4 -0
- package/dist-cjs/record/rpc.js +7 -3
- package/dist-cjs/registry/identify.d.ts +10 -1
- package/dist-cjs/registry/identify.js +4 -0
- package/dist-cjs/registry/on-chain-spec.d.ts +37 -0
- package/dist-cjs/registry/on-chain-spec.js +159 -0
- package/dist-cjs/review-card/builder.js +40 -0
- package/dist-cjs/review-card/cross-check.js +34 -0
- package/dist-cjs/review-card/render-leaf.d.ts +1 -1
- package/dist-cjs/review-card/render-leaf.js +7 -0
- package/dist-cjs/run/index.d.ts +25 -4
- package/dist-cjs/run/index.js +108 -2
- package/dist-cjs/run/schemas.d.ts +350 -6
- package/dist-cjs/run/schemas.js +96 -7
- package/dist-cjs/simulate/deny-cases.js +11 -0
- package/dist-cjs/simulate/evaluate.js +86 -5
- package/dist-cjs/synth/declare.d.ts +43 -0
- package/dist-cjs/synth/declare.js +130 -0
- package/dist-cjs/synth/index.d.ts +1 -0
- package/dist-cjs/synth/index.js +3 -1
- package/dist-cjs/synth/synthesize-from-recording.js +1 -1
- package/dist-cjs/types.d.ts +21 -1
- package/dist-cjs/types.js +1 -1
- package/package.json +1 -1
- package/src/adapters/interpreter/adapter.ts +13 -5
- package/src/errors.ts +5 -0
- package/src/install/authority-overlap.ts +323 -0
- package/src/install/index.ts +20 -0
- package/src/install/read-account-rules.ts +313 -0
- package/src/predicate/decode.ts +22 -1
- package/src/predicate/encode.ts +55 -5
- package/src/predicate/from-json.ts +14 -1
- package/src/record/index.ts +59 -2
- package/src/record/rpc.ts +4 -1
- package/src/registry/identify.ts +4 -1
- package/src/registry/on-chain-spec.ts +168 -0
- package/src/review-card/builder.ts +45 -2
- package/src/review-card/cross-check.ts +35 -1
- package/src/review-card/render-leaf.ts +8 -1
- package/src/run/index.ts +125 -2
- package/src/run/schemas.ts +100 -6
- package/src/simulate/deny-cases.ts +12 -1
- package/src/simulate/evaluate.ts +101 -9
- package/src/synth/declare.ts +206 -0
- package/src/synth/index.ts +5 -0
- package/src/synth/synthesize-from-recording.ts +1 -1
- package/src/types.ts +16 -1
package/src/errors.ts
CHANGED
|
@@ -43,6 +43,11 @@ export type ErrorCode =
|
|
|
43
43
|
| 'UNSUPPORTED_NODE'
|
|
44
44
|
| 'STATEFUL_BOUND'
|
|
45
45
|
| 'NOT_IN_ALLOWLIST'
|
|
46
|
+
/** A comparison against a `call_arg_scaled` operand failed (contract 107). */
|
|
47
|
+
| 'SLIPPAGE_FLOOR'
|
|
48
|
+
/** A `call_arg_scaled` ratio is zero or non-positive. Refused at install
|
|
49
|
+
* (contract 214) and mirrored at encode. */
|
|
50
|
+
| 'INVALID_SCALED_RATIO'
|
|
46
51
|
// --- escape-hatch compile gate ---
|
|
47
52
|
| 'COMPILE_OK'
|
|
48
53
|
| 'COMPILE_GATE_FAILED'
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// src/install/authority-overlap.ts - cross-rule authority analysis.
|
|
2
|
+
//
|
|
3
|
+
// An OZ smart account selects a context rule by CALLER DECLARATION and enforces
|
|
4
|
+
// only the policies of the rule that was named. A signer belonging to several
|
|
5
|
+
// rules therefore picks which one applies, so for any given call their
|
|
6
|
+
// authority is the MAXIMUM over the matching rules, never the intersection.
|
|
7
|
+
//
|
|
8
|
+
// The consequence is the one that catches people: installing a second, tighter
|
|
9
|
+
// rule restricts nothing. A key that also sits on an unpoliced rule is not
|
|
10
|
+
// constrained at all - it names that rule and the predicate never runs. This
|
|
11
|
+
// module detects that at install time, before the caller acts on a policy that
|
|
12
|
+
// looks binding and is not.
|
|
13
|
+
//
|
|
14
|
+
// Not theoretical. Proven on chain 2026-08-22: the same key, the same account
|
|
15
|
+
// and the same forbidden call was denied `#100` naming the policed rule and
|
|
16
|
+
// PERMITTED naming an unpoliced one. It happened in this project's own end-to-
|
|
17
|
+
// end harness, written by the author of the grammar, and was caught by review
|
|
18
|
+
// rather than by tooling - which is why the tooling now exists.
|
|
19
|
+
//
|
|
20
|
+
// Adapted to grammar 3 from the version published in `@crediolabs/policy-synth`
|
|
21
|
+
// 0.2.0, which came from the `octogate` repository and was lost when the npm
|
|
22
|
+
// lineage moved here. `or` and `not` are gone from the grammar, so the cases
|
|
23
|
+
// handling them are gone too; oracle bounds are gone from the stored document.
|
|
24
|
+
//
|
|
25
|
+
// Pure: no network. The caller supplies the account's rules.
|
|
26
|
+
|
|
27
|
+
import type { PredicateLeaf, PredicateNode, SignerDraft } from '../types.ts'
|
|
28
|
+
|
|
29
|
+
/** Wildcard component of a `Selector`: the predicate does not pin this half. */
|
|
30
|
+
export const ANY = '*'
|
|
31
|
+
|
|
32
|
+
/** A (contract, function) pair a predicate may permit. `ANY` in either half
|
|
33
|
+
* means unconstrained, so `{contract: ANY, fn: ANY}` is "any call at all". */
|
|
34
|
+
export interface Selector {
|
|
35
|
+
contract: string
|
|
36
|
+
fn: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ContextType =
|
|
40
|
+
| { kind: 'default' }
|
|
41
|
+
| { kind: 'call_contract'; contract: string }
|
|
42
|
+
| { kind: 'create_contract'; wasmHash: string }
|
|
43
|
+
|
|
44
|
+
/** How much can be said about a neighbouring rule.
|
|
45
|
+
* - `interpreter`: policed by our interpreter and the predicate was readable,
|
|
46
|
+
* so its authority is known exactly.
|
|
47
|
+
* - `foreign`: policed by some other contract. The address is visible, the
|
|
48
|
+
* semantics are not, so it needs review by hand.
|
|
49
|
+
* - `unpoliced`: no policy at all. Whatever its context type allows, its
|
|
50
|
+
* signers may do without constraint. */
|
|
51
|
+
export type RuleClass = 'interpreter' | 'foreign' | 'unpoliced'
|
|
52
|
+
|
|
53
|
+
export interface ObservedRule {
|
|
54
|
+
id: number
|
|
55
|
+
contextType: ContextType
|
|
56
|
+
signers: SignerDraft[]
|
|
57
|
+
/** Policy contract addresses attached to the rule, in OZ's order. */
|
|
58
|
+
policyAddresses: string[]
|
|
59
|
+
/** Decoded predicate. Present only when the rule is policed by OUR
|
|
60
|
+
* interpreter and the stored document was readable. */
|
|
61
|
+
predicate?: PredicateNode
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface IntendedInstall {
|
|
65
|
+
/** Rule the predicate is being installed onto. A re-install onto the same
|
|
66
|
+
* id REPLACES its predicate rather than adding a second source of
|
|
67
|
+
* authority, so that id is skipped. */
|
|
68
|
+
ruleId: number
|
|
69
|
+
contextType: ContextType
|
|
70
|
+
signers: SignerDraft[]
|
|
71
|
+
predicate: PredicateNode
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type OverlapSeverity =
|
|
75
|
+
/** A neighbouring rule imposes no constraint at all on the shared calls. */
|
|
76
|
+
| 'bypass'
|
|
77
|
+
/** A neighbouring policy exists but what it permits cannot be read. */
|
|
78
|
+
| 'unknown'
|
|
79
|
+
/** Both rules are ours. The new rule will not restrict the shared calls,
|
|
80
|
+
* because the signer names whichever is more permissive. */
|
|
81
|
+
| 'not-restricting'
|
|
82
|
+
|
|
83
|
+
export interface AuthorityOverlap {
|
|
84
|
+
ruleId: number
|
|
85
|
+
ruleClass: RuleClass
|
|
86
|
+
severity: OverlapSeverity
|
|
87
|
+
/** Signers present in BOTH rules. An overlap is only reachable by a signer
|
|
88
|
+
* who can name both, so a rule sharing no signer is not a collision. */
|
|
89
|
+
sharedSigners: SignerDraft[]
|
|
90
|
+
/** The selectors both rules can serve. Non-empty by construction. */
|
|
91
|
+
sharedSelectors: Selector[]
|
|
92
|
+
advice: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- signer identity ----
|
|
96
|
+
|
|
97
|
+
/** Canonical key for signer equality. Mirrors OZ's `Signer` enum: a delegated
|
|
98
|
+
* signer is its address, an external signer is the verifier plus the key
|
|
99
|
+
* bytes, since one verifier may hold many keys. */
|
|
100
|
+
export function signerKey(s: SignerDraft): string {
|
|
101
|
+
return s.kind === 'delegated' ? `delegated:${s.address}` : `external:${s.verifier}:${s.keyBytes}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sharedSigners(a: SignerDraft[], b: SignerDraft[]): SignerDraft[] {
|
|
105
|
+
const bKeys = new Set(b.map(signerKey))
|
|
106
|
+
return a.filter((s) => bKeys.has(signerKey(s)))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- selector extraction ----
|
|
110
|
+
|
|
111
|
+
const WILDCARD: Selector = { contract: ANY, fn: ANY }
|
|
112
|
+
|
|
113
|
+
function selectorKey(s: Selector): string {
|
|
114
|
+
return `${s.contract} ${s.fn}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function dedupe(sels: Selector[]): Selector[] {
|
|
118
|
+
const seen = new Map<string, Selector>()
|
|
119
|
+
for (const s of sels) seen.set(selectorKey(s), s)
|
|
120
|
+
return [...seen.values()]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Intersect one pair. `ANY` absorbs, equal literals survive, and two
|
|
124
|
+
* different literals cannot both hold for a single call. */
|
|
125
|
+
function intersectOne(a: Selector, b: Selector): Selector | null {
|
|
126
|
+
const contract =
|
|
127
|
+
a.contract === ANY
|
|
128
|
+
? b.contract
|
|
129
|
+
: b.contract === ANY
|
|
130
|
+
? a.contract
|
|
131
|
+
: a.contract === b.contract
|
|
132
|
+
? a.contract
|
|
133
|
+
: null
|
|
134
|
+
if (contract === null) return null
|
|
135
|
+
const fn = a.fn === ANY ? b.fn : b.fn === ANY ? a.fn : a.fn === b.fn ? a.fn : null
|
|
136
|
+
if (fn === null) return null
|
|
137
|
+
return { contract, fn }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Intersection of two selector SETS: every compatible pairing survives. */
|
|
141
|
+
export function intersectSelectors(a: Selector[], b: Selector[]): Selector[] {
|
|
142
|
+
const out: Selector[] = []
|
|
143
|
+
for (const x of a) {
|
|
144
|
+
for (const y of b) {
|
|
145
|
+
const hit = intersectOne(x, y)
|
|
146
|
+
if (hit) out.push(hit)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return dedupe(out)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function literalAddress(leaf: PredicateLeaf): string | null {
|
|
153
|
+
return leaf.kind === 'literal_address' ? leaf.value : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function literalSymbol(leaf: PredicateLeaf): string | null {
|
|
157
|
+
return leaf.kind === 'literal_symbol' ? leaf.value : null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Selector pinned by a single `eq`, whichever side the literal sits on. */
|
|
161
|
+
function selectorFromEq(left: PredicateLeaf, right: PredicateLeaf): Selector | null {
|
|
162
|
+
if (left.kind === 'call_contract') {
|
|
163
|
+
const addr = literalAddress(right)
|
|
164
|
+
return addr === null ? null : { contract: addr, fn: ANY }
|
|
165
|
+
}
|
|
166
|
+
if (right.kind === 'call_contract') {
|
|
167
|
+
const addr = literalAddress(left)
|
|
168
|
+
return addr === null ? null : { contract: addr, fn: ANY }
|
|
169
|
+
}
|
|
170
|
+
if (left.kind === 'call_fn') {
|
|
171
|
+
const sym = literalSymbol(right)
|
|
172
|
+
return sym === null ? null : { contract: ANY, fn: sym }
|
|
173
|
+
}
|
|
174
|
+
if (right.kind === 'call_fn') {
|
|
175
|
+
const sym = literalSymbol(left)
|
|
176
|
+
return sym === null ? null : { contract: ANY, fn: sym }
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The set of `(contract, fn)` selectors a predicate may permit.
|
|
183
|
+
*
|
|
184
|
+
* A deliberate OVER-approximation: every call the predicate actually permits is
|
|
185
|
+
* covered by some returned selector, and unrecognised structure widens to the
|
|
186
|
+
* wildcard rather than narrowing. That direction is what makes the emptiness
|
|
187
|
+
* test sound. A call carries exactly one `(contract, fn)`, so if two
|
|
188
|
+
* predicates' over-approximations do not intersect, no single call can be
|
|
189
|
+
* routed to either and the rules provably cannot collide.
|
|
190
|
+
*
|
|
191
|
+
* Narrowing instead would be the fail-OPEN direction: it would let this report
|
|
192
|
+
* "no overlap" for rules that do collide.
|
|
193
|
+
*/
|
|
194
|
+
export function permittedSelectors(node: PredicateNode): Selector[] {
|
|
195
|
+
switch (node.op) {
|
|
196
|
+
case 'and': {
|
|
197
|
+
// Every conjunct must hold at once, so the permitted set is the
|
|
198
|
+
// intersection. Intersecting over-approximations stays one.
|
|
199
|
+
let acc: Selector[] = [WILDCARD]
|
|
200
|
+
for (const child of node.children) acc = intersectSelectors(acc, permittedSelectors(child))
|
|
201
|
+
return acc
|
|
202
|
+
}
|
|
203
|
+
case 'or': {
|
|
204
|
+
// Any branch may hold, so the permitted set is the UNION. A union of
|
|
205
|
+
// over-approximations is still an over-approximation, so this keeps the
|
|
206
|
+
// fail-safe direction while staying tighter than the wildcard the
|
|
207
|
+
// default branch would give. Precision matters here: `or` is how a
|
|
208
|
+
// policy says "pair A or pair B", and widening that to the wildcard
|
|
209
|
+
// would report an overlap against every rule on the account.
|
|
210
|
+
const acc: Selector[] = []
|
|
211
|
+
for (const child of node.children) acc.push(...permittedSelectors(child))
|
|
212
|
+
return dedupe(acc)
|
|
213
|
+
}
|
|
214
|
+
case 'eq': {
|
|
215
|
+
const sel = selectorFromEq(node.left, node.right)
|
|
216
|
+
return sel === null ? [WILDCARD] : [sel]
|
|
217
|
+
}
|
|
218
|
+
case 'in': {
|
|
219
|
+
// Set membership over the selector halves: `call_fn in {a, b}` permits
|
|
220
|
+
// both. A haystack element that is not the matching literal kind makes
|
|
221
|
+
// the node uninformative rather than narrower.
|
|
222
|
+
if (node.needle.kind === 'call_contract') {
|
|
223
|
+
const addrs = node.haystack.map(literalAddress)
|
|
224
|
+
if (addrs.some((a) => a === null)) return [WILDCARD]
|
|
225
|
+
return dedupe((addrs as string[]).map((a) => ({ contract: a, fn: ANY })))
|
|
226
|
+
}
|
|
227
|
+
if (node.needle.kind === 'call_fn') {
|
|
228
|
+
const syms = node.haystack.map(literalSymbol)
|
|
229
|
+
if (syms.some((s) => s === null)) return [WILDCARD]
|
|
230
|
+
return dedupe((syms as string[]).map((s) => ({ contract: ANY, fn: s })))
|
|
231
|
+
}
|
|
232
|
+
return [WILDCARD]
|
|
233
|
+
}
|
|
234
|
+
default:
|
|
235
|
+
// `lte` binds an amount, never the selector.
|
|
236
|
+
return [WILDCARD]
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Selectors a context type admits, before the predicate narrows them. */
|
|
241
|
+
export function selectorsForContextType(ct: ContextType): Selector[] {
|
|
242
|
+
switch (ct.kind) {
|
|
243
|
+
case 'default':
|
|
244
|
+
return [WILDCARD]
|
|
245
|
+
case 'call_contract':
|
|
246
|
+
return [{ contract: ct.contract, fn: ANY }]
|
|
247
|
+
case 'create_contract':
|
|
248
|
+
// A contract-creation context is a different `Context` shape. The
|
|
249
|
+
// interpreter refuses anything that is not `Context::Contract`, and a
|
|
250
|
+
// creation rule can never serve a call, so it shares no selector.
|
|
251
|
+
return []
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** What a rule can actually authorise: its context type narrowed by its
|
|
256
|
+
* predicate. An unpoliced or unreadable rule contributes no narrowing. */
|
|
257
|
+
export function effectiveSelectors(rule: ObservedRule): Selector[] {
|
|
258
|
+
const fromType = selectorsForContextType(rule.contextType)
|
|
259
|
+
if (!rule.predicate) return fromType
|
|
260
|
+
return intersectSelectors(fromType, permittedSelectors(rule.predicate))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function classifyRule(rule: ObservedRule): RuleClass {
|
|
264
|
+
if (rule.policyAddresses.length === 0) return 'unpoliced'
|
|
265
|
+
return rule.predicate ? 'interpreter' : 'foreign'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function adviceFor(cls: RuleClass, ruleId: number): string {
|
|
269
|
+
switch (cls) {
|
|
270
|
+
case 'unpoliced':
|
|
271
|
+
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
|
+
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.`
|
|
274
|
+
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.`
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Every existing rule a signer of the intended install could name instead.
|
|
281
|
+
*
|
|
282
|
+
* A rule collides when it shares at least one signer AND at least one selector.
|
|
283
|
+
* Both are needed for the signer to have a choice: same signer but disjoint
|
|
284
|
+
* calls means no call can be rerouted, and same calls but no shared signer
|
|
285
|
+
* means nobody can reroute them.
|
|
286
|
+
*/
|
|
287
|
+
export function findAuthorityOverlaps(args: {
|
|
288
|
+
intended: IntendedInstall
|
|
289
|
+
existing: ObservedRule[]
|
|
290
|
+
}): AuthorityOverlap[] {
|
|
291
|
+
const intendedSelectors = intersectSelectors(
|
|
292
|
+
selectorsForContextType(args.intended.contextType),
|
|
293
|
+
permittedSelectors(args.intended.predicate)
|
|
294
|
+
)
|
|
295
|
+
const out: AuthorityOverlap[] = []
|
|
296
|
+
|
|
297
|
+
for (const rule of args.existing) {
|
|
298
|
+
if (rule.id === args.intended.ruleId) continue
|
|
299
|
+
|
|
300
|
+
const shared = sharedSigners(args.intended.signers, rule.signers)
|
|
301
|
+
if (shared.length === 0) continue
|
|
302
|
+
|
|
303
|
+
const sharedSelectors = intersectSelectors(intendedSelectors, effectiveSelectors(rule))
|
|
304
|
+
if (sharedSelectors.length === 0) continue
|
|
305
|
+
|
|
306
|
+
const ruleClass = classifyRule(rule)
|
|
307
|
+
out.push({
|
|
308
|
+
ruleId: rule.id,
|
|
309
|
+
ruleClass,
|
|
310
|
+
severity:
|
|
311
|
+
ruleClass === 'unpoliced'
|
|
312
|
+
? 'bypass'
|
|
313
|
+
: ruleClass === 'foreign'
|
|
314
|
+
? 'unknown'
|
|
315
|
+
: 'not-restricting',
|
|
316
|
+
sharedSigners: shared,
|
|
317
|
+
sharedSelectors,
|
|
318
|
+
advice: adviceFor(ruleClass, rule.id),
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return out
|
|
323
|
+
}
|
package/src/install/index.ts
CHANGED
|
@@ -12,6 +12,26 @@
|
|
|
12
12
|
// Exported here rather than from the package root to keep the root surface
|
|
13
13
|
// about synthesis, and because these are transaction-building primitives whose
|
|
14
14
|
// callers should know they are reaching for them.
|
|
15
|
+
|
|
16
|
+
// Cross-rule authority analysis. Exported because the check has to happen
|
|
17
|
+
// wherever an install is BUILT, and a client that assembles its own
|
|
18
|
+
// `add_context_rule` call never reaches `runInstallPolicy`.
|
|
19
|
+
export {
|
|
20
|
+
ANY,
|
|
21
|
+
type AuthorityOverlap,
|
|
22
|
+
type ContextType,
|
|
23
|
+
effectiveSelectors,
|
|
24
|
+
findAuthorityOverlaps,
|
|
25
|
+
type IntendedInstall,
|
|
26
|
+
intersectSelectors,
|
|
27
|
+
type ObservedRule,
|
|
28
|
+
type OverlapSeverity,
|
|
29
|
+
permittedSelectors,
|
|
30
|
+
type RuleClass,
|
|
31
|
+
type Selector,
|
|
32
|
+
selectorsForContextType,
|
|
33
|
+
signerKey,
|
|
34
|
+
} from './authority-overlap.ts'
|
|
15
35
|
export {
|
|
16
36
|
ADD_CONTEXT_RULE_SYMBOL,
|
|
17
37
|
type AddContextRuleArgs,
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
//! Reading an OpenZeppelin smart account's context rules back off chain.
|
|
2
|
+
//!
|
|
3
|
+
//! `authority-overlap.ts` needs to know what a signer can already do before a
|
|
4
|
+
//! new policy is installed. That means every rule on the account: its context
|
|
5
|
+
//! type, its signers, its attached policies, and - for rules our interpreter
|
|
6
|
+
//! polices - the predicate itself.
|
|
7
|
+
//!
|
|
8
|
+
//! Without this, the overlap scan can only report on rules the CALLER supplied,
|
|
9
|
+
//! which means it answers "what did you tell me about" rather than "what is on
|
|
10
|
+
//! the account". Those are different questions, and only the second one is
|
|
11
|
+
//! worth anything to someone deciding whether to sign.
|
|
12
|
+
//!
|
|
13
|
+
//! The predicate is NOT reachable through a contract call. The interpreter
|
|
14
|
+
//! publishes only `grammar_version`, `install`, `enforce`, `uninstall` and
|
|
15
|
+
//! `rotate_master_signer_set`, so the stored document is read as a ledger entry
|
|
16
|
+
//! instead. That keeps this a purely client-side capability: adding a getter
|
|
17
|
+
//! would change a deployed contract's ABI and force a redeploy plus re-audit to
|
|
18
|
+
//! obtain data the ledger already exposes.
|
|
19
|
+
//!
|
|
20
|
+
//! The decoders are pure so they can be tested without a network; the caller
|
|
21
|
+
//! supplies raw `ScVal`s.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Account,
|
|
25
|
+
Address,
|
|
26
|
+
BASE_FEE,
|
|
27
|
+
Contract,
|
|
28
|
+
Keypair,
|
|
29
|
+
rpc,
|
|
30
|
+
TransactionBuilder,
|
|
31
|
+
xdr,
|
|
32
|
+
} from '@stellar/stellar-sdk'
|
|
33
|
+
import { decodePredicate } from '../predicate/decode.ts'
|
|
34
|
+
import type { SignerDraft } from '../types.ts'
|
|
35
|
+
import type { ContextType, ObservedRule } from './authority-overlap.ts'
|
|
36
|
+
|
|
37
|
+
/** `storage.rs` - the third element of the persistent doc key tuple. */
|
|
38
|
+
export const K_DOC = 1
|
|
39
|
+
|
|
40
|
+
/** Persistent-storage key for a rule's stored document:
|
|
41
|
+
* `(account, rule_id, K_DOC)`. */
|
|
42
|
+
export function docKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal {
|
|
43
|
+
return xdr.ScVal.scvVec([
|
|
44
|
+
new Address(smartAccount).toScVal(),
|
|
45
|
+
xdr.ScVal.scvU32(ruleId),
|
|
46
|
+
xdr.ScVal.scvU32(K_DOC),
|
|
47
|
+
])
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Ledger key for the interpreter's persistent entry holding that document. */
|
|
51
|
+
export function docLedgerKey(
|
|
52
|
+
interpreter: string,
|
|
53
|
+
smartAccount: string,
|
|
54
|
+
ruleId: number
|
|
55
|
+
): xdr.LedgerKey {
|
|
56
|
+
return xdr.LedgerKey.contractData(
|
|
57
|
+
new xdr.LedgerKeyContractData({
|
|
58
|
+
contract: new Address(interpreter).toScAddress(),
|
|
59
|
+
key: docKeyScVal(smartAccount, ruleId),
|
|
60
|
+
durability: xdr.ContractDataDurability.persistent(),
|
|
61
|
+
})
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---- ScVal helpers -----
|
|
66
|
+
|
|
67
|
+
/** Field of a `#[contracttype]` struct, which the host encodes as a map keyed
|
|
68
|
+
* by field-name symbol. Returns undefined when the field is absent so a
|
|
69
|
+
* caller can distinguish "not there" from "there and empty". */
|
|
70
|
+
function mapField(v: xdr.ScVal, name: string): xdr.ScVal | undefined {
|
|
71
|
+
if (v.switch() !== xdr.ScValType.scvMap()) return undefined
|
|
72
|
+
for (const entry of v.map() ?? []) {
|
|
73
|
+
const key = entry.key()
|
|
74
|
+
if (key.switch() === xdr.ScValType.scvSymbol() && key.sym().toString() === name) {
|
|
75
|
+
return entry.val()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function u32Of(v: xdr.ScVal | undefined): number | undefined {
|
|
82
|
+
return v?.switch() === xdr.ScValType.scvU32() ? v.u32() : undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function addressOf(v: xdr.ScVal | undefined): string | undefined {
|
|
86
|
+
if (!v || v.switch() !== xdr.ScValType.scvAddress()) return undefined
|
|
87
|
+
return Address.fromScAddress(v.address()).toString()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** An enum variant of a `#[contracttype]` enum: `ScVal::Vec([Symbol, ...args])`. */
|
|
91
|
+
function enumVariant(v: xdr.ScVal | undefined): { tag: string; args: xdr.ScVal[] } | undefined {
|
|
92
|
+
if (!v || v.switch() !== xdr.ScValType.scvVec()) return undefined
|
|
93
|
+
const items = v.vec() ?? []
|
|
94
|
+
const head = items[0]
|
|
95
|
+
if (!head || head.switch() !== xdr.ScValType.scvSymbol()) return undefined
|
|
96
|
+
return { tag: head.sym().toString(), args: items.slice(1) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---- decoders -----
|
|
100
|
+
|
|
101
|
+
/** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
|
|
102
|
+
* is the widest reading and therefore the safe one: it makes the rule look
|
|
103
|
+
* like it could serve any call, so overlap is over-reported, never missed. */
|
|
104
|
+
export function decodeContextType(v: xdr.ScVal | undefined): ContextType {
|
|
105
|
+
const variant = enumVariant(v)
|
|
106
|
+
if (!variant) return { kind: 'default' }
|
|
107
|
+
if (variant.tag === 'CallContract') {
|
|
108
|
+
const addr = addressOf(variant.args[0])
|
|
109
|
+
return addr ? { kind: 'call_contract', contract: addr } : { kind: 'default' }
|
|
110
|
+
}
|
|
111
|
+
if (variant.tag === 'CreateContract') {
|
|
112
|
+
const arg = variant.args[0]
|
|
113
|
+
const hash = arg?.switch() === xdr.ScValType.scvBytes() ? arg.bytes().toString('hex') : ''
|
|
114
|
+
return { kind: 'create_contract', wasmHash: hash }
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'default' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
|
|
120
|
+
export function decodeSigner(v: xdr.ScVal): SignerDraft | undefined {
|
|
121
|
+
const variant = enumVariant(v)
|
|
122
|
+
if (!variant) return undefined
|
|
123
|
+
if (variant.tag === 'Delegated') {
|
|
124
|
+
const addr = addressOf(variant.args[0])
|
|
125
|
+
return addr ? { kind: 'delegated', address: addr } : undefined
|
|
126
|
+
}
|
|
127
|
+
if (variant.tag === 'External') {
|
|
128
|
+
const verifier = addressOf(variant.args[0])
|
|
129
|
+
const keyArg = variant.args[1]
|
|
130
|
+
const keyBytes =
|
|
131
|
+
keyArg?.switch() === xdr.ScValType.scvBytes() ? keyArg.bytes().toString('hex') : ''
|
|
132
|
+
return verifier ? { kind: 'external', verifier, keyBytes } : undefined
|
|
133
|
+
}
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
|
|
138
|
+
* `predicate` is filled in separately from the ledger entry. */
|
|
139
|
+
export function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined {
|
|
140
|
+
const id = u32Of(mapField(v, 'id'))
|
|
141
|
+
if (id === undefined) return undefined
|
|
142
|
+
|
|
143
|
+
const signersVal = mapField(v, 'signers')
|
|
144
|
+
const signers: SignerDraft[] = []
|
|
145
|
+
if (signersVal?.switch() === xdr.ScValType.scvVec()) {
|
|
146
|
+
for (const s of signersVal.vec() ?? []) {
|
|
147
|
+
const decoded = decodeSigner(s)
|
|
148
|
+
if (decoded) signers.push(decoded)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const policiesVal = mapField(v, 'policies')
|
|
153
|
+
const policyAddresses: string[] = []
|
|
154
|
+
if (policiesVal?.switch() === xdr.ScValType.scvVec()) {
|
|
155
|
+
for (const p of policiesVal.vec() ?? []) {
|
|
156
|
+
const addr = addressOf(p)
|
|
157
|
+
if (addr) policyAddresses.push(addr)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
contextType: decodeContextType(mapField(v, 'context_type')),
|
|
164
|
+
signers,
|
|
165
|
+
policyAddresses,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The interpreter's `StoredDoc { predicate_bytes }`. */
|
|
170
|
+
export function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined {
|
|
171
|
+
const field = mapField(v, 'predicate_bytes')
|
|
172
|
+
if (!field || field.switch() !== xdr.ScValType.scvBytes()) return undefined
|
|
173
|
+
return field.bytes()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- collection -----
|
|
177
|
+
|
|
178
|
+
/** The three reads the scan needs. Kept as an interface so the collection
|
|
179
|
+
* below is testable without a network. */
|
|
180
|
+
export interface AccountRuleReader {
|
|
181
|
+
/** OZ `get_context_rules_count()`. */
|
|
182
|
+
getContextRuleCount(smartAccount: string): Promise<number>
|
|
183
|
+
/** OZ `get_context_rule(id)`. Undefined when the id is absent. */
|
|
184
|
+
getContextRule(smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>
|
|
185
|
+
/** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
|
|
186
|
+
* Undefined when no document is stored for that rule. */
|
|
187
|
+
getStoredDoc(
|
|
188
|
+
interpreter: string,
|
|
189
|
+
smartAccount: string,
|
|
190
|
+
ruleId: number
|
|
191
|
+
): Promise<xdr.ScVal | undefined>
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** How far the id scan will probe before giving up. OZ imposes no per-account
|
|
195
|
+
* rule cap, so there is no exact bound to derive; this one is far above any
|
|
196
|
+
* realistic account and keeps a malformed `Count` from spinning forever. */
|
|
197
|
+
export const MAX_RULE_ID_SCAN = 512
|
|
198
|
+
|
|
199
|
+
export interface CollectedRules {
|
|
200
|
+
rules: ObservedRule[]
|
|
201
|
+
/** Rule ids whose stored predicate could not be read even though the
|
|
202
|
+
* interpreter is attached. Such a rule is reported without a predicate,
|
|
203
|
+
* which classifies it as opaque rather than as safely narrow. */
|
|
204
|
+
unreadablePredicateRuleIds: number[]
|
|
205
|
+
/** True when the scan stopped before accounting for every live rule. The
|
|
206
|
+
* result is then a SUBSET of the account's rules, so an empty overlap list
|
|
207
|
+
* proves nothing and the caller must not present it as safety. */
|
|
208
|
+
incomplete: boolean
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Every context rule on the account, with predicates filled in for the rules
|
|
213
|
+
* our interpreter polices.
|
|
214
|
+
*
|
|
215
|
+
* Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
|
|
216
|
+
* decrements `Count` on removal without ever reusing an id, so after any
|
|
217
|
+
* removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
|
|
218
|
+
* would silently skip live rules at higher ids, and a skipped rule is a missed
|
|
219
|
+
* overlap - the one error that reports safety which does not exist. Instead the
|
|
220
|
+
* scan walks ids upward until it has accounted for `Count` live rules.
|
|
221
|
+
*
|
|
222
|
+
* A rule whose predicate cannot be read is deliberately left without one. That
|
|
223
|
+
* demotes it to the `foreign` class, so the scan reports it as opaque instead
|
|
224
|
+
* of assuming it is narrow.
|
|
225
|
+
*/
|
|
226
|
+
export async function collectObservedRules(args: {
|
|
227
|
+
reader: AccountRuleReader
|
|
228
|
+
smartAccount: string
|
|
229
|
+
interpreterAddress: string
|
|
230
|
+
maxRuleIdScan?: number
|
|
231
|
+
}): Promise<CollectedRules> {
|
|
232
|
+
const count = await args.reader.getContextRuleCount(args.smartAccount)
|
|
233
|
+
const limit = args.maxRuleIdScan ?? MAX_RULE_ID_SCAN
|
|
234
|
+
const rules: ObservedRule[] = []
|
|
235
|
+
const unreadablePredicateRuleIds: number[] = []
|
|
236
|
+
|
|
237
|
+
let id = 0
|
|
238
|
+
while (rules.length < count && id < limit) {
|
|
239
|
+
const raw = await args.reader.getContextRule(args.smartAccount, id)
|
|
240
|
+
id++
|
|
241
|
+
if (!raw) continue
|
|
242
|
+
const rule = decodeContextRule(raw)
|
|
243
|
+
if (!rule) continue
|
|
244
|
+
|
|
245
|
+
if (rule.policyAddresses.includes(args.interpreterAddress)) {
|
|
246
|
+
const doc = await args.reader.getStoredDoc(
|
|
247
|
+
args.interpreterAddress,
|
|
248
|
+
args.smartAccount,
|
|
249
|
+
rule.id
|
|
250
|
+
)
|
|
251
|
+
const bytes = doc ? decodeStoredPredicateBytes(doc) : undefined
|
|
252
|
+
if (bytes) {
|
|
253
|
+
try {
|
|
254
|
+
rule.predicate = decodePredicate(bytes)
|
|
255
|
+
} catch {
|
|
256
|
+
unreadablePredicateRuleIds.push(rule.id)
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
unreadablePredicateRuleIds.push(rule.id)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
rules.push(rule)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { rules, unreadablePredicateRuleIds, incomplete: rules.length < count }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An `AccountRuleReader` over a live RPC server.
|
|
270
|
+
*
|
|
271
|
+
* The two OZ getters are read-only simulations: the source account is
|
|
272
|
+
* constructed locally because a simulation never checks its sequence number,
|
|
273
|
+
* and asking the network for a random key would 404.
|
|
274
|
+
*
|
|
275
|
+
* The stored document is fetched as a ledger entry rather than a contract
|
|
276
|
+
* call, because the interpreter publishes no getter for it.
|
|
277
|
+
*/
|
|
278
|
+
export function accountRuleReaderFromServer(
|
|
279
|
+
server: rpc.Server,
|
|
280
|
+
networkPassphrase: string
|
|
281
|
+
): AccountRuleReader {
|
|
282
|
+
async function simulateCall(
|
|
283
|
+
contract: string,
|
|
284
|
+
method: string,
|
|
285
|
+
...args: xdr.ScVal[]
|
|
286
|
+
): Promise<xdr.ScVal | undefined> {
|
|
287
|
+
const account = new Account(Keypair.random().publicKey(), '0')
|
|
288
|
+
const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase })
|
|
289
|
+
.addOperation(new Contract(contract).call(method, ...args))
|
|
290
|
+
.setTimeout(30)
|
|
291
|
+
.build()
|
|
292
|
+
const sim = await server.simulateTransaction(tx)
|
|
293
|
+
if (rpc.Api.isSimulationError(sim)) return undefined
|
|
294
|
+
return sim.result?.retval
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
async getContextRuleCount(smartAccount) {
|
|
299
|
+
const val = await simulateCall(smartAccount, 'get_context_rules_count')
|
|
300
|
+
return u32Of(val) ?? 0
|
|
301
|
+
},
|
|
302
|
+
async getContextRule(smartAccount, ruleId) {
|
|
303
|
+
return simulateCall(smartAccount, 'get_context_rule', xdr.ScVal.scvU32(ruleId))
|
|
304
|
+
},
|
|
305
|
+
async getStoredDoc(interpreter, smartAccount, ruleId) {
|
|
306
|
+
const key = docLedgerKey(interpreter, smartAccount, ruleId)
|
|
307
|
+
const res = await server.getLedgerEntries(key)
|
|
308
|
+
const entry = res.entries?.[0]?.val
|
|
309
|
+
if (!entry || entry.switch() !== xdr.LedgerEntryType.contractData()) return undefined
|
|
310
|
+
return entry.contractData().val()
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|