@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
|
@@ -12,9 +12,9 @@ export interface InterpreterAdapterConfig {
|
|
|
12
12
|
* rejected as `SCOPE_SELF_CALL`. */
|
|
13
13
|
smartAccountAddress: string;
|
|
14
14
|
}
|
|
15
|
-
/** The result of compiling a
|
|
15
|
+
/** The result of compiling a `ComposedRule` for this backend. */
|
|
16
16
|
export interface CompileResult {
|
|
17
|
-
/** false => some
|
|
17
|
+
/** false => some constraint this backend cannot express (see `uncovered`). */
|
|
18
18
|
covered: boolean;
|
|
19
19
|
/** Human-readable list of unsupported constructs. */
|
|
20
20
|
uncovered: string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// src/adapters/interpreter/adapter.ts - the interpreter-policy
|
|
1
|
+
// src/adapters/interpreter/adapter.ts - the interpreter-policy adapter.
|
|
2
2
|
//
|
|
3
|
-
// Compiles a
|
|
3
|
+
// Compiles a `ComposedRule` to a single interpreter `PolicyDocument` + `PolicyRef`
|
|
4
4
|
// carrying the canonical predicate encoding from `predicate/encode.ts`. This is
|
|
5
5
|
// the only backend; the compose step lowers every constraint to it.
|
|
6
6
|
//
|
|
@@ -133,7 +133,11 @@ function assertNoSelfCall(node, config) {
|
|
|
133
133
|
}
|
|
134
134
|
};
|
|
135
135
|
switch (node.op) {
|
|
136
|
+
// Both boolean nodes recurse. `or` is listed explicitly rather than left
|
|
137
|
+
// to a default branch: this is a security check, and a new node kind must
|
|
138
|
+
// fail to compile here rather than silently skip it.
|
|
136
139
|
case 'and':
|
|
140
|
+
case 'or':
|
|
137
141
|
for (const c of node.children)
|
|
138
142
|
assertNoSelfCall(c, config);
|
|
139
143
|
return;
|
|
@@ -142,7 +146,11 @@ function assertNoSelfCall(node, config) {
|
|
|
142
146
|
for (const h of node.haystack)
|
|
143
147
|
checkLeaf(h);
|
|
144
148
|
return;
|
|
145
|
-
|
|
149
|
+
case 'eq':
|
|
150
|
+
case 'lt':
|
|
151
|
+
case 'lte':
|
|
152
|
+
case 'gt':
|
|
153
|
+
case 'gte':
|
|
146
154
|
checkLeaf(node.left);
|
|
147
155
|
checkLeaf(node.right);
|
|
148
156
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ARG_MISMATCH' | 'CONTRACT_SCOPE' | 'UNSUPPORTED_NODE' | 'STATEFUL_BOUND' | 'NOT_IN_ALLOWLIST'
|
|
1
|
+
export type ErrorCode = 'RECORDING_FAILED' | 'RECORDING_VALIDATION_FAILED' | 'SCOPE_UNRESOLVED' | 'SYNTHESIS_ERROR' | 'MALFORMED_PREDICATE' | 'SIMULATION_ERROR' | 'VERIFICATION_FAILED' | 'DENY_CASE_FAILURE' | 'PERMIT_CASE_FAILED' | 'SUMMARY_DRIFT' | 'INSTALL_BUILD_FAILED' | 'INSTALL_CONFIRM_MISSING' | 'INSTALL_CONFIRM_EXPIRED' | 'REVOKE_BUILD_FAILED' | 'REVOKE_CONFIRM_MISSING' | 'USER_REJECTED_SIGN' | 'WALLET_TIMEOUT' | 'WALLET_UNAVAILABLE' | 'PREDICATE_TOO_LARGE' | 'PREDICATE_TOO_DEEP' | 'TOO_MANY_LEAVES' | 'IN_OPERAND_LIMIT' | 'POLICY_CAP_EXCEEDED' | 'WASM_TOO_LARGE' | 'MASTER_AUTH_REQUIRED' | 'NONCE_REPLAY' | 'VERSION_MISMATCH' | 'ARITHMETIC_OVERFLOW' | 'AMOUNT_OVERFLOW' | 'RULE_SIGNERS_CHANGED' | 'SCOPE_SELF_CALL' | 'ARG_MISMATCH' | 'CONTRACT_SCOPE' | 'UNSUPPORTED_NODE' | 'STATEFUL_BOUND' | 'NOT_IN_ALLOWLIST'
|
|
2
|
+
/** A comparison against a `call_arg_scaled` operand failed (contract 107). */
|
|
3
|
+
| 'SLIPPAGE_FLOOR'
|
|
4
|
+
/** A `call_arg_scaled` ratio is zero or non-positive. Refused at install
|
|
5
|
+
* (contract 214) and mirrored at encode. */
|
|
6
|
+
| 'INVALID_SCALED_RATIO' | 'COMPILE_OK' | 'COMPILE_GATE_FAILED';
|
|
2
7
|
export interface ToolError {
|
|
3
8
|
code: ErrorCode;
|
|
4
9
|
message: string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PredicateNode, SignerDraft } from '../types.ts';
|
|
2
|
+
/** Wildcard component of a `Selector`: the predicate does not pin this half. */
|
|
3
|
+
export declare const ANY = "*";
|
|
4
|
+
/** A (contract, function) pair a predicate may permit. `ANY` in either half
|
|
5
|
+
* means unconstrained, so `{contract: ANY, fn: ANY}` is "any call at all". */
|
|
6
|
+
export interface Selector {
|
|
7
|
+
contract: string;
|
|
8
|
+
fn: string;
|
|
9
|
+
}
|
|
10
|
+
export type ContextType = {
|
|
11
|
+
kind: 'default';
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'call_contract';
|
|
14
|
+
contract: string;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'create_contract';
|
|
17
|
+
wasmHash: string;
|
|
18
|
+
};
|
|
19
|
+
/** How much can be said about a neighbouring rule.
|
|
20
|
+
* - `interpreter`: policed by our interpreter and the predicate was readable,
|
|
21
|
+
* so its authority is known exactly.
|
|
22
|
+
* - `foreign`: policed by some other contract. The address is visible, the
|
|
23
|
+
* semantics are not, so it needs review by hand.
|
|
24
|
+
* - `unpoliced`: no policy at all. Whatever its context type allows, its
|
|
25
|
+
* signers may do without constraint. */
|
|
26
|
+
export type RuleClass = 'interpreter' | 'foreign' | 'unpoliced';
|
|
27
|
+
export interface ObservedRule {
|
|
28
|
+
id: number;
|
|
29
|
+
contextType: ContextType;
|
|
30
|
+
signers: SignerDraft[];
|
|
31
|
+
/** Policy contract addresses attached to the rule, in OZ's order. */
|
|
32
|
+
policyAddresses: string[];
|
|
33
|
+
/** Decoded predicate. Present only when the rule is policed by OUR
|
|
34
|
+
* interpreter and the stored document was readable. */
|
|
35
|
+
predicate?: PredicateNode;
|
|
36
|
+
}
|
|
37
|
+
export interface IntendedInstall {
|
|
38
|
+
/** Rule the predicate is being installed onto. A re-install onto the same
|
|
39
|
+
* id REPLACES its predicate rather than adding a second source of
|
|
40
|
+
* authority, so that id is skipped. */
|
|
41
|
+
ruleId: number;
|
|
42
|
+
contextType: ContextType;
|
|
43
|
+
signers: SignerDraft[];
|
|
44
|
+
predicate: PredicateNode;
|
|
45
|
+
}
|
|
46
|
+
export type OverlapSeverity =
|
|
47
|
+
/** A neighbouring rule imposes no constraint at all on the shared calls. */
|
|
48
|
+
'bypass'
|
|
49
|
+
/** A neighbouring policy exists but what it permits cannot be read. */
|
|
50
|
+
| 'unknown'
|
|
51
|
+
/** Both rules are ours. The new rule will not restrict the shared calls,
|
|
52
|
+
* because the signer names whichever is more permissive. */
|
|
53
|
+
| 'not-restricting';
|
|
54
|
+
export interface AuthorityOverlap {
|
|
55
|
+
ruleId: number;
|
|
56
|
+
ruleClass: RuleClass;
|
|
57
|
+
severity: OverlapSeverity;
|
|
58
|
+
/** Signers present in BOTH rules. An overlap is only reachable by a signer
|
|
59
|
+
* who can name both, so a rule sharing no signer is not a collision. */
|
|
60
|
+
sharedSigners: SignerDraft[];
|
|
61
|
+
/** The selectors both rules can serve. Non-empty by construction. */
|
|
62
|
+
sharedSelectors: Selector[];
|
|
63
|
+
advice: string;
|
|
64
|
+
}
|
|
65
|
+
/** Canonical key for signer equality. Mirrors OZ's `Signer` enum: a delegated
|
|
66
|
+
* signer is its address, an external signer is the verifier plus the key
|
|
67
|
+
* bytes, since one verifier may hold many keys. */
|
|
68
|
+
export declare function signerKey(s: SignerDraft): string;
|
|
69
|
+
/** Intersection of two selector SETS: every compatible pairing survives. */
|
|
70
|
+
export declare function intersectSelectors(a: Selector[], b: Selector[]): Selector[];
|
|
71
|
+
/**
|
|
72
|
+
* The set of `(contract, fn)` selectors a predicate may permit.
|
|
73
|
+
*
|
|
74
|
+
* A deliberate OVER-approximation: every call the predicate actually permits is
|
|
75
|
+
* covered by some returned selector, and unrecognised structure widens to the
|
|
76
|
+
* wildcard rather than narrowing. That direction is what makes the emptiness
|
|
77
|
+
* test sound. A call carries exactly one `(contract, fn)`, so if two
|
|
78
|
+
* predicates' over-approximations do not intersect, no single call can be
|
|
79
|
+
* routed to either and the rules provably cannot collide.
|
|
80
|
+
*
|
|
81
|
+
* Narrowing instead would be the fail-OPEN direction: it would let this report
|
|
82
|
+
* "no overlap" for rules that do collide.
|
|
83
|
+
*/
|
|
84
|
+
export declare function permittedSelectors(node: PredicateNode): Selector[];
|
|
85
|
+
/** Selectors a context type admits, before the predicate narrows them. */
|
|
86
|
+
export declare function selectorsForContextType(ct: ContextType): Selector[];
|
|
87
|
+
/** What a rule can actually authorise: its context type narrowed by its
|
|
88
|
+
* predicate. An unpoliced or unreadable rule contributes no narrowing. */
|
|
89
|
+
export declare function effectiveSelectors(rule: ObservedRule): Selector[];
|
|
90
|
+
/**
|
|
91
|
+
* Every existing rule a signer of the intended install could name instead.
|
|
92
|
+
*
|
|
93
|
+
* A rule collides when it shares at least one signer AND at least one selector.
|
|
94
|
+
* Both are needed for the signer to have a choice: same signer but disjoint
|
|
95
|
+
* calls means no call can be rerouted, and same calls but no shared signer
|
|
96
|
+
* means nobody can reroute them.
|
|
97
|
+
*/
|
|
98
|
+
export declare function findAuthorityOverlaps(args: {
|
|
99
|
+
intended: IntendedInstall;
|
|
100
|
+
existing: ObservedRule[];
|
|
101
|
+
}): AuthorityOverlap[];
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
/** Wildcard component of a `Selector`: the predicate does not pin this half. */
|
|
27
|
+
export const ANY = '*';
|
|
28
|
+
// ---- signer identity ----
|
|
29
|
+
/** Canonical key for signer equality. Mirrors OZ's `Signer` enum: a delegated
|
|
30
|
+
* signer is its address, an external signer is the verifier plus the key
|
|
31
|
+
* bytes, since one verifier may hold many keys. */
|
|
32
|
+
export function signerKey(s) {
|
|
33
|
+
return s.kind === 'delegated' ? `delegated:${s.address}` : `external:${s.verifier}:${s.keyBytes}`;
|
|
34
|
+
}
|
|
35
|
+
function sharedSigners(a, b) {
|
|
36
|
+
const bKeys = new Set(b.map(signerKey));
|
|
37
|
+
return a.filter((s) => bKeys.has(signerKey(s)));
|
|
38
|
+
}
|
|
39
|
+
// ---- selector extraction ----
|
|
40
|
+
const WILDCARD = { contract: ANY, fn: ANY };
|
|
41
|
+
function selectorKey(s) {
|
|
42
|
+
return `${s.contract} ${s.fn}`;
|
|
43
|
+
}
|
|
44
|
+
function dedupe(sels) {
|
|
45
|
+
const seen = new Map();
|
|
46
|
+
for (const s of sels)
|
|
47
|
+
seen.set(selectorKey(s), s);
|
|
48
|
+
return [...seen.values()];
|
|
49
|
+
}
|
|
50
|
+
/** Intersect one pair. `ANY` absorbs, equal literals survive, and two
|
|
51
|
+
* different literals cannot both hold for a single call. */
|
|
52
|
+
function intersectOne(a, b) {
|
|
53
|
+
const contract = a.contract === ANY
|
|
54
|
+
? b.contract
|
|
55
|
+
: b.contract === ANY
|
|
56
|
+
? a.contract
|
|
57
|
+
: a.contract === b.contract
|
|
58
|
+
? a.contract
|
|
59
|
+
: null;
|
|
60
|
+
if (contract === null)
|
|
61
|
+
return null;
|
|
62
|
+
const fn = a.fn === ANY ? b.fn : b.fn === ANY ? a.fn : a.fn === b.fn ? a.fn : null;
|
|
63
|
+
if (fn === null)
|
|
64
|
+
return null;
|
|
65
|
+
return { contract, fn };
|
|
66
|
+
}
|
|
67
|
+
/** Intersection of two selector SETS: every compatible pairing survives. */
|
|
68
|
+
export function intersectSelectors(a, b) {
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const x of a) {
|
|
71
|
+
for (const y of b) {
|
|
72
|
+
const hit = intersectOne(x, y);
|
|
73
|
+
if (hit)
|
|
74
|
+
out.push(hit);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return dedupe(out);
|
|
78
|
+
}
|
|
79
|
+
function literalAddress(leaf) {
|
|
80
|
+
return leaf.kind === 'literal_address' ? leaf.value : null;
|
|
81
|
+
}
|
|
82
|
+
function literalSymbol(leaf) {
|
|
83
|
+
return leaf.kind === 'literal_symbol' ? leaf.value : null;
|
|
84
|
+
}
|
|
85
|
+
/** Selector pinned by a single `eq`, whichever side the literal sits on. */
|
|
86
|
+
function selectorFromEq(left, right) {
|
|
87
|
+
if (left.kind === 'call_contract') {
|
|
88
|
+
const addr = literalAddress(right);
|
|
89
|
+
return addr === null ? null : { contract: addr, fn: ANY };
|
|
90
|
+
}
|
|
91
|
+
if (right.kind === 'call_contract') {
|
|
92
|
+
const addr = literalAddress(left);
|
|
93
|
+
return addr === null ? null : { contract: addr, fn: ANY };
|
|
94
|
+
}
|
|
95
|
+
if (left.kind === 'call_fn') {
|
|
96
|
+
const sym = literalSymbol(right);
|
|
97
|
+
return sym === null ? null : { contract: ANY, fn: sym };
|
|
98
|
+
}
|
|
99
|
+
if (right.kind === 'call_fn') {
|
|
100
|
+
const sym = literalSymbol(left);
|
|
101
|
+
return sym === null ? null : { contract: ANY, fn: sym };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The set of `(contract, fn)` selectors a predicate may permit.
|
|
107
|
+
*
|
|
108
|
+
* A deliberate OVER-approximation: every call the predicate actually permits is
|
|
109
|
+
* covered by some returned selector, and unrecognised structure widens to the
|
|
110
|
+
* wildcard rather than narrowing. That direction is what makes the emptiness
|
|
111
|
+
* test sound. A call carries exactly one `(contract, fn)`, so if two
|
|
112
|
+
* predicates' over-approximations do not intersect, no single call can be
|
|
113
|
+
* routed to either and the rules provably cannot collide.
|
|
114
|
+
*
|
|
115
|
+
* Narrowing instead would be the fail-OPEN direction: it would let this report
|
|
116
|
+
* "no overlap" for rules that do collide.
|
|
117
|
+
*/
|
|
118
|
+
export function permittedSelectors(node) {
|
|
119
|
+
switch (node.op) {
|
|
120
|
+
case 'and': {
|
|
121
|
+
// Every conjunct must hold at once, so the permitted set is the
|
|
122
|
+
// intersection. Intersecting over-approximations stays one.
|
|
123
|
+
let acc = [WILDCARD];
|
|
124
|
+
for (const child of node.children)
|
|
125
|
+
acc = intersectSelectors(acc, permittedSelectors(child));
|
|
126
|
+
return acc;
|
|
127
|
+
}
|
|
128
|
+
case 'or': {
|
|
129
|
+
// Any branch may hold, so the permitted set is the UNION. A union of
|
|
130
|
+
// over-approximations is still an over-approximation, so this keeps the
|
|
131
|
+
// fail-safe direction while staying tighter than the wildcard the
|
|
132
|
+
// default branch would give. Precision matters here: `or` is how a
|
|
133
|
+
// policy says "pair A or pair B", and widening that to the wildcard
|
|
134
|
+
// would report an overlap against every rule on the account.
|
|
135
|
+
const acc = [];
|
|
136
|
+
for (const child of node.children)
|
|
137
|
+
acc.push(...permittedSelectors(child));
|
|
138
|
+
return dedupe(acc);
|
|
139
|
+
}
|
|
140
|
+
case 'eq': {
|
|
141
|
+
const sel = selectorFromEq(node.left, node.right);
|
|
142
|
+
return sel === null ? [WILDCARD] : [sel];
|
|
143
|
+
}
|
|
144
|
+
case 'in': {
|
|
145
|
+
// Set membership over the selector halves: `call_fn in {a, b}` permits
|
|
146
|
+
// both. A haystack element that is not the matching literal kind makes
|
|
147
|
+
// the node uninformative rather than narrower.
|
|
148
|
+
if (node.needle.kind === 'call_contract') {
|
|
149
|
+
const addrs = node.haystack.map(literalAddress);
|
|
150
|
+
if (addrs.some((a) => a === null))
|
|
151
|
+
return [WILDCARD];
|
|
152
|
+
return dedupe(addrs.map((a) => ({ contract: a, fn: ANY })));
|
|
153
|
+
}
|
|
154
|
+
if (node.needle.kind === 'call_fn') {
|
|
155
|
+
const syms = node.haystack.map(literalSymbol);
|
|
156
|
+
if (syms.some((s) => s === null))
|
|
157
|
+
return [WILDCARD];
|
|
158
|
+
return dedupe(syms.map((s) => ({ contract: ANY, fn: s })));
|
|
159
|
+
}
|
|
160
|
+
return [WILDCARD];
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
// `lte` binds an amount, never the selector.
|
|
164
|
+
return [WILDCARD];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Selectors a context type admits, before the predicate narrows them. */
|
|
168
|
+
export function selectorsForContextType(ct) {
|
|
169
|
+
switch (ct.kind) {
|
|
170
|
+
case 'default':
|
|
171
|
+
return [WILDCARD];
|
|
172
|
+
case 'call_contract':
|
|
173
|
+
return [{ contract: ct.contract, fn: ANY }];
|
|
174
|
+
case 'create_contract':
|
|
175
|
+
// A contract-creation context is a different `Context` shape. The
|
|
176
|
+
// interpreter refuses anything that is not `Context::Contract`, and a
|
|
177
|
+
// creation rule can never serve a call, so it shares no selector.
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** What a rule can actually authorise: its context type narrowed by its
|
|
182
|
+
* predicate. An unpoliced or unreadable rule contributes no narrowing. */
|
|
183
|
+
export function effectiveSelectors(rule) {
|
|
184
|
+
const fromType = selectorsForContextType(rule.contextType);
|
|
185
|
+
if (!rule.predicate)
|
|
186
|
+
return fromType;
|
|
187
|
+
return intersectSelectors(fromType, permittedSelectors(rule.predicate));
|
|
188
|
+
}
|
|
189
|
+
function classifyRule(rule) {
|
|
190
|
+
if (rule.policyAddresses.length === 0)
|
|
191
|
+
return 'unpoliced';
|
|
192
|
+
return rule.predicate ? 'interpreter' : 'foreign';
|
|
193
|
+
}
|
|
194
|
+
function adviceFor(cls, ruleId) {
|
|
195
|
+
switch (cls) {
|
|
196
|
+
case 'unpoliced':
|
|
197
|
+
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.`;
|
|
198
|
+
case 'foreign':
|
|
199
|
+
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.`;
|
|
200
|
+
case 'interpreter':
|
|
201
|
+
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.`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Every existing rule a signer of the intended install could name instead.
|
|
206
|
+
*
|
|
207
|
+
* A rule collides when it shares at least one signer AND at least one selector.
|
|
208
|
+
* Both are needed for the signer to have a choice: same signer but disjoint
|
|
209
|
+
* calls means no call can be rerouted, and same calls but no shared signer
|
|
210
|
+
* means nobody can reroute them.
|
|
211
|
+
*/
|
|
212
|
+
export function findAuthorityOverlaps(args) {
|
|
213
|
+
const intendedSelectors = intersectSelectors(selectorsForContextType(args.intended.contextType), permittedSelectors(args.intended.predicate));
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const rule of args.existing) {
|
|
216
|
+
if (rule.id === args.intended.ruleId)
|
|
217
|
+
continue;
|
|
218
|
+
const shared = sharedSigners(args.intended.signers, rule.signers);
|
|
219
|
+
if (shared.length === 0)
|
|
220
|
+
continue;
|
|
221
|
+
const sharedSelectors = intersectSelectors(intendedSelectors, effectiveSelectors(rule));
|
|
222
|
+
if (sharedSelectors.length === 0)
|
|
223
|
+
continue;
|
|
224
|
+
const ruleClass = classifyRule(rule);
|
|
225
|
+
out.push({
|
|
226
|
+
ruleId: rule.id,
|
|
227
|
+
ruleClass,
|
|
228
|
+
severity: ruleClass === 'unpoliced'
|
|
229
|
+
? 'bypass'
|
|
230
|
+
: ruleClass === 'foreign'
|
|
231
|
+
? 'unknown'
|
|
232
|
+
: 'not-restricting',
|
|
233
|
+
sharedSigners: shared,
|
|
234
|
+
sharedSelectors,
|
|
235
|
+
advice: adviceFor(ruleClass, rule.id),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
@@ -23,7 +23,7 @@ export interface BuildAddContextRuleArgs {
|
|
|
23
23
|
* before the chain does; the contract will refuse a mismatch again. */
|
|
24
24
|
grammarVersion?: number;
|
|
25
25
|
}
|
|
26
|
-
export declare const DEFAULT_GRAMMAR_VERSION:
|
|
26
|
+
export declare const DEFAULT_GRAMMAR_VERSION: 4;
|
|
27
27
|
/** The verb `add_context_rule` takes on the wire. */
|
|
28
28
|
export declare const ADD_CONTEXT_RULE_SYMBOL: "add_context_rule";
|
|
29
29
|
/** Tuple of `ScVal` arguments to pass to `Operation.invokeHostFunction` for
|
package/dist/install/index.d.ts
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
+
export { ANY, type AuthorityOverlap, type ContextType, effectiveSelectors, findAuthorityOverlaps, type IntendedInstall, intersectSelectors, type ObservedRule, type OverlapSeverity, permittedSelectors, type RuleClass, type Selector, selectorsForContextType, signerKey, } from './authority-overlap.ts';
|
|
1
2
|
export { ADD_CONTEXT_RULE_SYMBOL, type AddContextRuleArgs, type BuildAddContextRuleArgs, buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION, } from './build-add-context-rule.ts';
|
package/dist/install/index.js
CHANGED
|
@@ -12,4 +12,8 @@
|
|
|
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
|
+
// Cross-rule authority analysis. Exported because the check has to happen
|
|
16
|
+
// wherever an install is BUILT, and a client that assembles its own
|
|
17
|
+
// `add_context_rule` call never reaches `runInstallPolicy`.
|
|
18
|
+
export { ANY, effectiveSelectors, findAuthorityOverlaps, intersectSelectors, permittedSelectors, selectorsForContextType, signerKey, } from "./authority-overlap.js";
|
|
15
19
|
export { ADD_CONTEXT_RULE_SYMBOL, buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION, } from "./build-add-context-rule.js";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { rpc, xdr } from '@stellar/stellar-sdk';
|
|
2
|
+
import type { SignerDraft } from '../types.ts';
|
|
3
|
+
import type { ContextType, ObservedRule } from './authority-overlap.ts';
|
|
4
|
+
/** `storage.rs` - the third element of the persistent doc key tuple. */
|
|
5
|
+
export declare const K_DOC = 1;
|
|
6
|
+
/** Persistent-storage key for a rule's stored document:
|
|
7
|
+
* `(account, rule_id, K_DOC)`. */
|
|
8
|
+
export declare function docKeyScVal(smartAccount: string, ruleId: number): xdr.ScVal;
|
|
9
|
+
/** Ledger key for the interpreter's persistent entry holding that document. */
|
|
10
|
+
export declare function docLedgerKey(interpreter: string, smartAccount: string, ruleId: number): xdr.LedgerKey;
|
|
11
|
+
/** OZ `ContextRuleType`. An unrecognised tag is reported as `default`, which
|
|
12
|
+
* is the widest reading and therefore the safe one: it makes the rule look
|
|
13
|
+
* like it could serve any call, so overlap is over-reported, never missed. */
|
|
14
|
+
export declare function decodeContextType(v: xdr.ScVal | undefined): ContextType;
|
|
15
|
+
/** OZ `Signer::Delegated(Address) | Signer::External(Address, Bytes)`. */
|
|
16
|
+
export declare function decodeSigner(v: xdr.ScVal): SignerDraft | undefined;
|
|
17
|
+
/** A full OZ `ContextRule` as returned by `get_context_rule(id)`.
|
|
18
|
+
* `predicate` is filled in separately from the ledger entry. */
|
|
19
|
+
export declare function decodeContextRule(v: xdr.ScVal): ObservedRule | undefined;
|
|
20
|
+
/** The interpreter's `StoredDoc { predicate_bytes }`. */
|
|
21
|
+
export declare function decodeStoredPredicateBytes(v: xdr.ScVal): Buffer | undefined;
|
|
22
|
+
/** The three reads the scan needs. Kept as an interface so the collection
|
|
23
|
+
* below is testable without a network. */
|
|
24
|
+
export interface AccountRuleReader {
|
|
25
|
+
/** OZ `get_context_rules_count()`. */
|
|
26
|
+
getContextRuleCount(smartAccount: string): Promise<number>;
|
|
27
|
+
/** OZ `get_context_rule(id)`. Undefined when the id is absent. */
|
|
28
|
+
getContextRule(smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
|
|
29
|
+
/** The interpreter's persistent `StoredDoc` entry, read as a ledger entry.
|
|
30
|
+
* Undefined when no document is stored for that rule. */
|
|
31
|
+
getStoredDoc(interpreter: string, smartAccount: string, ruleId: number): Promise<xdr.ScVal | undefined>;
|
|
32
|
+
}
|
|
33
|
+
/** How far the id scan will probe before giving up. OZ imposes no per-account
|
|
34
|
+
* rule cap, so there is no exact bound to derive; this one is far above any
|
|
35
|
+
* realistic account and keeps a malformed `Count` from spinning forever. */
|
|
36
|
+
export declare const MAX_RULE_ID_SCAN = 512;
|
|
37
|
+
export interface CollectedRules {
|
|
38
|
+
rules: ObservedRule[];
|
|
39
|
+
/** Rule ids whose stored predicate could not be read even though the
|
|
40
|
+
* interpreter is attached. Such a rule is reported without a predicate,
|
|
41
|
+
* which classifies it as opaque rather than as safely narrow. */
|
|
42
|
+
unreadablePredicateRuleIds: number[];
|
|
43
|
+
/** True when the scan stopped before accounting for every live rule. The
|
|
44
|
+
* result is then a SUBSET of the account's rules, so an empty overlap list
|
|
45
|
+
* proves nothing and the caller must not present it as safety. */
|
|
46
|
+
incomplete: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every context rule on the account, with predicates filled in for the rules
|
|
50
|
+
* our interpreter polices.
|
|
51
|
+
*
|
|
52
|
+
* Rule ids are NOT contiguous. OZ assigns them from a monotonic `NextId` and
|
|
53
|
+
* decrements `Count` on removal without ever reusing an id, so after any
|
|
54
|
+
* removal `Count < NextId` and the live ids have gaps. Iterating `0..Count-1`
|
|
55
|
+
* would silently skip live rules at higher ids, and a skipped rule is a missed
|
|
56
|
+
* overlap - the one error that reports safety which does not exist. Instead the
|
|
57
|
+
* scan walks ids upward until it has accounted for `Count` live rules.
|
|
58
|
+
*
|
|
59
|
+
* A rule whose predicate cannot be read is deliberately left without one. That
|
|
60
|
+
* demotes it to the `foreign` class, so the scan reports it as opaque instead
|
|
61
|
+
* of assuming it is narrow.
|
|
62
|
+
*/
|
|
63
|
+
export declare function collectObservedRules(args: {
|
|
64
|
+
reader: AccountRuleReader;
|
|
65
|
+
smartAccount: string;
|
|
66
|
+
interpreterAddress: string;
|
|
67
|
+
maxRuleIdScan?: number;
|
|
68
|
+
}): Promise<CollectedRules>;
|
|
69
|
+
/**
|
|
70
|
+
* An `AccountRuleReader` over a live RPC server.
|
|
71
|
+
*
|
|
72
|
+
* The two OZ getters are read-only simulations: the source account is
|
|
73
|
+
* constructed locally because a simulation never checks its sequence number,
|
|
74
|
+
* and asking the network for a random key would 404.
|
|
75
|
+
*
|
|
76
|
+
* The stored document is fetched as a ledger entry rather than a contract
|
|
77
|
+
* call, because the interpreter publishes no getter for it.
|
|
78
|
+
*/
|
|
79
|
+
export declare function accountRuleReaderFromServer(server: rpc.Server, networkPassphrase: string): AccountRuleReader;
|