@crediolabs/policy-synth 0.5.2 → 0.5.4

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.
@@ -107,12 +107,33 @@ function leftArgLabel(leaf) {
107
107
  }
108
108
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
109
109
  * shape of the output is pinned by Task 7b so the test suite can assert
110
- * byte-for-byte equality. Returns `null` when the node is a structural
111
- * boolean (`and`) - that is not a constraint leaf. */
110
+ * byte-for-byte equality. Returns `null` only when a node's shape cannot be
111
+ * rendered at all. */
112
112
  function renderConstraint(node) {
113
113
  switch (node.op) {
114
- case 'and':
115
- return null;
114
+ case 'and': {
115
+ // Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
116
+ // descends into a top-level `and` and never calls this on one.
117
+ //
118
+ // This used to return null, on the reasoning that `and` is structural
119
+ // rather than a constraint leaf. That is true for the walk path and
120
+ // false for this one, and the combination silently dropped real
121
+ // policies: `or(and, and)` is the natural shape of a policy with
122
+ // alternative permitted forms - one conjunction of pins per branch -
123
+ // so the `or` case below always saw null children and withheld the
124
+ // entire disjunction. `describePredicate` then returned an EMPTY list
125
+ // for a restrictive policy, which any caller renders as "no
126
+ // constraints".
127
+ //
128
+ // That inverts the very guard it was protecting: the `or` case
129
+ // withholds to avoid reading STRICTER than reality, but withholding
130
+ // the only line reads as UNRESTRICTED, which is far worse. Compose
131
+ // instead, and keep the withhold for genuinely unrenderable children.
132
+ const parts = node.children.map(renderConstraint);
133
+ if (parts.some((p) => p === null))
134
+ return null;
135
+ return parts.join(' and ');
136
+ }
116
137
  case 'or': {
117
138
  // One line for the whole disjunction. If any branch is a shape the
118
139
  // card cannot render, the entire line is withheld rather than shown
@@ -20,6 +20,10 @@ export interface ComposeUserResponses {
20
20
  export interface ComposeOptions {
21
21
  network: Network;
22
22
  userResponses?: ComposeUserResponses;
23
+ /** The smart account this rule will be installed on, when known. Used only
24
+ * to detect self-scope (see the warning below `scopeContract` is set) -
25
+ * composition proceeds identically when this is omitted. */
26
+ smartAccountAddress?: string;
23
27
  }
24
28
  /** One composed rule: what the call must be scoped to, the constraints on it,
25
29
  * and how long the resulting context rule lives. The constraints are already
@@ -2,9 +2,12 @@
2
2
  //
3
3
  // Fail-closed composition rules:
4
4
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
5
- // emit no constraint from the spend; scope is kept (contract + method) and
6
- // every inferred bound surfaces as a descriptive warning. An unrecognised
7
- // call never compiles to a permissive policy.
5
+ // no ABI means no argument can be singled out by role (recipient, spender,
6
+ // etc.), so a spend is never capped and no argument is guessed at; every
7
+ // address argument the call actually carries is pinned to its observed
8
+ // value instead (AC-33.19), and every other inferred bound surfaces as a
9
+ // descriptive warning. An unrecognised call never compiles to a
10
+ // permissive policy.
8
11
  // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
9
12
  // the call carries an amount argument to bind it to. A single recorded
10
13
  // spend does NOT authorise that amount on every call, so the observed
@@ -80,9 +83,8 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
80
83
  }
81
84
  }
82
85
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
83
- // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
84
- // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
85
- // the swapRecipientAllowlist surface.
86
+ // interpreter adapter lowers to an `in` predicate. SoroSwap's swap recipient
87
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
86
88
  if (topLevel && protocol !== null) {
87
89
  // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
88
90
  // input-amount argument ONLY when no outgoing spend was detected for the
@@ -93,6 +95,41 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
93
95
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
94
96
  appendProtocolSpecificConstraints(interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap);
95
97
  }
98
+ else if (topLevel && protocol === null) {
99
+ // Unrecognised protocol: there is no published ABI, so no argument can be
100
+ // singled out as THE recipient - guessing an index risks pinning the
101
+ // wrong value (false security) or a value that means something else
102
+ // entirely for a different contract sharing the same shape. What the
103
+ // recording DOES evidence, without any interpretation, is every address
104
+ // literally carried by the call. AC-33.19: a value the recording
105
+ // evidences is pinned by default, never left open because its semantic
106
+ // role is unrecognised - so every address argument is pinned to its
107
+ // observed value, not just the one a known ABI would call "to".
108
+ const addressArgIndexes = topLevel.args.flatMap((arg, i) => (arg.type === 'address' ? [i] : []));
109
+ if (addressArgIndexes.length > 0) {
110
+ for (const i of addressArgIndexes) {
111
+ const arg = topLevel.args[i];
112
+ if (arg?.type !== 'address')
113
+ continue;
114
+ interpreterConstraints.push({
115
+ op: 'in',
116
+ needle: { kind: 'call_arg', index: i },
117
+ haystack: [{ kind: 'literal_address', value: arg.value }],
118
+ });
119
+ }
120
+ warnings.push(`unrecognised protocol: every address argument observed in the call (index ${addressArgIndexes.join(', ')}) was pinned to its recorded value - the ABI is unknown, so no single argument could be identified as the recipient specifically; read this pin with less confidence than a recognised protocol's`);
121
+ }
122
+ }
123
+ // Self-scope: a rule whose scope targets the smart account that will hold
124
+ // it lets the account authorise calls against its own governance surface
125
+ // (e.g. a recorded batch_add_signer against the account itself). Such a
126
+ // rule installs cleanly and then denies every OTHER call with OZ error
127
+ // #3002, bricking the account. Detected only when the caller supplies
128
+ // smartAccountAddress; without it the composer has nothing to compare
129
+ // scopeContract against, so composition proceeds unchanged.
130
+ if (opts.smartAccountAddress !== undefined && scopeContract === opts.smartAccountAddress) {
131
+ warnings.push(`scope.contract (${scopeContract}) is the smart account's own address: a rule scoping ${topLevel?.fn ?? 'this call'} to the account that will hold it lets the account govern its own governance surface, and denies every other call with OZ error #3002 once installed`);
132
+ }
96
133
  // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
97
134
  // predicate leaf.
98
135
  const scope = { contract: scopeContract };
@@ -148,6 +148,9 @@ function synthesizeFromRecordingInner(tx, opts) {
148
148
  const composeOpts = {
149
149
  network: opts.network,
150
150
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
151
+ ...(opts.interpreter?.smartAccountAddress !== undefined
152
+ ? { smartAccountAddress: opts.interpreter.smartAccountAddress }
153
+ : {}),
151
154
  };
152
155
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
153
156
  // --explain hook: capture the in-memory predicate tree + the real
@@ -111,12 +111,33 @@ function leftArgLabel(leaf) {
111
111
  }
112
112
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
113
113
  * shape of the output is pinned by Task 7b so the test suite can assert
114
- * byte-for-byte equality. Returns `null` when the node is a structural
115
- * boolean (`and`) - that is not a constraint leaf. */
114
+ * byte-for-byte equality. Returns `null` only when a node's shape cannot be
115
+ * rendered at all. */
116
116
  function renderConstraint(node) {
117
117
  switch (node.op) {
118
- case 'and':
119
- return null;
118
+ case 'and': {
119
+ // Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
120
+ // descends into a top-level `and` and never calls this on one.
121
+ //
122
+ // This used to return null, on the reasoning that `and` is structural
123
+ // rather than a constraint leaf. That is true for the walk path and
124
+ // false for this one, and the combination silently dropped real
125
+ // policies: `or(and, and)` is the natural shape of a policy with
126
+ // alternative permitted forms - one conjunction of pins per branch -
127
+ // so the `or` case below always saw null children and withheld the
128
+ // entire disjunction. `describePredicate` then returned an EMPTY list
129
+ // for a restrictive policy, which any caller renders as "no
130
+ // constraints".
131
+ //
132
+ // That inverts the very guard it was protecting: the `or` case
133
+ // withholds to avoid reading STRICTER than reality, but withholding
134
+ // the only line reads as UNRESTRICTED, which is far worse. Compose
135
+ // instead, and keep the withhold for genuinely unrenderable children.
136
+ const parts = node.children.map(renderConstraint);
137
+ if (parts.some((p) => p === null))
138
+ return null;
139
+ return parts.join(' and ');
140
+ }
120
141
  case 'or': {
121
142
  // One line for the whole disjunction. If any branch is a shape the
122
143
  // card cannot render, the entire line is withheld rather than shown
@@ -20,6 +20,10 @@ export interface ComposeUserResponses {
20
20
  export interface ComposeOptions {
21
21
  network: Network;
22
22
  userResponses?: ComposeUserResponses;
23
+ /** The smart account this rule will be installed on, when known. Used only
24
+ * to detect self-scope (see the warning below `scopeContract` is set) -
25
+ * composition proceeds identically when this is omitted. */
26
+ smartAccountAddress?: string;
23
27
  }
24
28
  /** One composed rule: what the call must be scoped to, the constraints on it,
25
29
  * and how long the resulting context rule lives. The constraints are already
@@ -3,9 +3,12 @@
3
3
  //
4
4
  // Fail-closed composition rules:
5
5
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
6
- // emit no constraint from the spend; scope is kept (contract + method) and
7
- // every inferred bound surfaces as a descriptive warning. An unrecognised
8
- // call never compiles to a permissive policy.
6
+ // no ABI means no argument can be singled out by role (recipient, spender,
7
+ // etc.), so a spend is never capped and no argument is guessed at; every
8
+ // address argument the call actually carries is pinned to its observed
9
+ // value instead (AC-33.19), and every other inferred bound surfaces as a
10
+ // descriptive warning. An unrecognised call never compiles to a
11
+ // permissive policy.
9
12
  // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
10
13
  // the call carries an amount argument to bind it to. A single recorded
11
14
  // spend does NOT authorise that amount on every call, so the observed
@@ -83,9 +86,8 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
83
86
  }
84
87
  }
85
88
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
86
- // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
87
- // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
88
- // the swapRecipientAllowlist surface.
89
+ // interpreter adapter lowers to an `in` predicate. SoroSwap's swap recipient
90
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
89
91
  if (topLevel && protocol !== null) {
90
92
  // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
91
93
  // input-amount argument ONLY when no outgoing spend was detected for the
@@ -96,6 +98,41 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
96
98
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
97
99
  appendProtocolSpecificConstraints(interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap);
98
100
  }
101
+ else if (topLevel && protocol === null) {
102
+ // Unrecognised protocol: there is no published ABI, so no argument can be
103
+ // singled out as THE recipient - guessing an index risks pinning the
104
+ // wrong value (false security) or a value that means something else
105
+ // entirely for a different contract sharing the same shape. What the
106
+ // recording DOES evidence, without any interpretation, is every address
107
+ // literally carried by the call. AC-33.19: a value the recording
108
+ // evidences is pinned by default, never left open because its semantic
109
+ // role is unrecognised - so every address argument is pinned to its
110
+ // observed value, not just the one a known ABI would call "to".
111
+ const addressArgIndexes = topLevel.args.flatMap((arg, i) => (arg.type === 'address' ? [i] : []));
112
+ if (addressArgIndexes.length > 0) {
113
+ for (const i of addressArgIndexes) {
114
+ const arg = topLevel.args[i];
115
+ if (arg?.type !== 'address')
116
+ continue;
117
+ interpreterConstraints.push({
118
+ op: 'in',
119
+ needle: { kind: 'call_arg', index: i },
120
+ haystack: [{ kind: 'literal_address', value: arg.value }],
121
+ });
122
+ }
123
+ warnings.push(`unrecognised protocol: every address argument observed in the call (index ${addressArgIndexes.join(', ')}) was pinned to its recorded value - the ABI is unknown, so no single argument could be identified as the recipient specifically; read this pin with less confidence than a recognised protocol's`);
124
+ }
125
+ }
126
+ // Self-scope: a rule whose scope targets the smart account that will hold
127
+ // it lets the account authorise calls against its own governance surface
128
+ // (e.g. a recorded batch_add_signer against the account itself). Such a
129
+ // rule installs cleanly and then denies every OTHER call with OZ error
130
+ // #3002, bricking the account. Detected only when the caller supplies
131
+ // smartAccountAddress; without it the composer has nothing to compare
132
+ // scopeContract against, so composition proceeds unchanged.
133
+ if (opts.smartAccountAddress !== undefined && scopeContract === opts.smartAccountAddress) {
134
+ warnings.push(`scope.contract (${scopeContract}) is the smart account's own address: a rule scoping ${topLevel?.fn ?? 'this call'} to the account that will hold it lets the account govern its own governance surface, and denies every other call with OZ error #3002 once installed`);
135
+ }
99
136
  // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
100
137
  // predicate leaf.
101
138
  const scope = { contract: scopeContract };
@@ -152,6 +152,9 @@ function synthesizeFromRecordingInner(tx, opts) {
152
152
  const composeOpts = {
153
153
  network: opts.network,
154
154
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
155
+ ...(opts.interpreter?.smartAccountAddress !== undefined
156
+ ? { smartAccountAddress: opts.interpreter.smartAccountAddress }
157
+ : {}),
155
158
  };
156
159
  const composed = (0, compose_from_recording_ts_1.composeFromRecording)(facts, scope.contract, topLevel, composeOpts);
157
160
  // --explain hook: capture the in-memory predicate tree + the real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -134,12 +134,32 @@ function leftArgLabel(leaf: PredicateLeaf): string {
134
134
 
135
135
  /** Render ONE constraint sentence for ONE interpreter predicate node. The
136
136
  * shape of the output is pinned by Task 7b so the test suite can assert
137
- * byte-for-byte equality. Returns `null` when the node is a structural
138
- * boolean (`and`) - that is not a constraint leaf. */
137
+ * byte-for-byte equality. Returns `null` only when a node's shape cannot be
138
+ * rendered at all. */
139
139
  function renderConstraint(node: PredicateNode): string | null {
140
140
  switch (node.op) {
141
- case 'and':
142
- return null
141
+ case 'and': {
142
+ // Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
143
+ // descends into a top-level `and` and never calls this on one.
144
+ //
145
+ // This used to return null, on the reasoning that `and` is structural
146
+ // rather than a constraint leaf. That is true for the walk path and
147
+ // false for this one, and the combination silently dropped real
148
+ // policies: `or(and, and)` is the natural shape of a policy with
149
+ // alternative permitted forms - one conjunction of pins per branch -
150
+ // so the `or` case below always saw null children and withheld the
151
+ // entire disjunction. `describePredicate` then returned an EMPTY list
152
+ // for a restrictive policy, which any caller renders as "no
153
+ // constraints".
154
+ //
155
+ // That inverts the very guard it was protecting: the `or` case
156
+ // withholds to avoid reading STRICTER than reality, but withholding
157
+ // the only line reads as UNRESTRICTED, which is far worse. Compose
158
+ // instead, and keep the withhold for genuinely unrenderable children.
159
+ const parts = node.children.map(renderConstraint)
160
+ if (parts.some((p) => p === null)) return null
161
+ return parts.join(' and ')
162
+ }
143
163
  case 'or': {
144
164
  // One line for the whole disjunction. If any branch is a shape the
145
165
  // card cannot render, the entire line is withheld rather than shown
@@ -2,9 +2,12 @@
2
2
  //
3
3
  // Fail-closed composition rules:
4
4
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
5
- // emit no constraint from the spend; scope is kept (contract + method) and
6
- // every inferred bound surfaces as a descriptive warning. An unrecognised
7
- // call never compiles to a permissive policy.
5
+ // no ABI means no argument can be singled out by role (recipient, spender,
6
+ // etc.), so a spend is never capped and no argument is guessed at; every
7
+ // address argument the call actually carries is pinned to its observed
8
+ // value instead (AC-33.19), and every other inferred bound surfaces as a
9
+ // descriptive warning. An unrecognised call never compiles to a
10
+ // permissive policy.
8
11
  // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
9
12
  // the call carries an amount argument to bind it to. A single recorded
10
13
  // spend does NOT authorise that amount on every call, so the observed
@@ -43,6 +46,10 @@ export interface ComposeUserResponses {
43
46
  export interface ComposeOptions {
44
47
  network: Network
45
48
  userResponses?: ComposeUserResponses
49
+ /** The smart account this rule will be installed on, when known. Used only
50
+ * to detect self-scope (see the warning below `scopeContract` is set) -
51
+ * composition proceeds identically when this is omitted. */
52
+ smartAccountAddress?: string
46
53
  }
47
54
 
48
55
  /** One composed rule: what the call must be scoped to, the constraints on it,
@@ -145,9 +152,8 @@ export function composeFromRecording(
145
152
  }
146
153
 
147
154
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
148
- // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
149
- // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
150
- // the swapRecipientAllowlist surface.
155
+ // interpreter adapter lowers to an `in` predicate. SoroSwap's swap recipient
156
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
151
157
  if (topLevel && protocol !== null) {
152
158
  // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
153
159
  // input-amount argument ONLY when no outgoing spend was detected for the
@@ -166,6 +172,44 @@ export function composeFromRecording(
166
172
  opts.userResponses?.swapRecipientAllowlist,
167
173
  swapInputAmountCap
168
174
  )
175
+ } else if (topLevel && protocol === null) {
176
+ // Unrecognised protocol: there is no published ABI, so no argument can be
177
+ // singled out as THE recipient - guessing an index risks pinning the
178
+ // wrong value (false security) or a value that means something else
179
+ // entirely for a different contract sharing the same shape. What the
180
+ // recording DOES evidence, without any interpretation, is every address
181
+ // literally carried by the call. AC-33.19: a value the recording
182
+ // evidences is pinned by default, never left open because its semantic
183
+ // role is unrecognised - so every address argument is pinned to its
184
+ // observed value, not just the one a known ABI would call "to".
185
+ const addressArgIndexes = topLevel.args.flatMap((arg, i) => (arg.type === 'address' ? [i] : []))
186
+ if (addressArgIndexes.length > 0) {
187
+ for (const i of addressArgIndexes) {
188
+ const arg = topLevel.args[i]
189
+ if (arg?.type !== 'address') continue
190
+ interpreterConstraints.push({
191
+ op: 'in',
192
+ needle: { kind: 'call_arg', index: i },
193
+ haystack: [{ kind: 'literal_address', value: arg.value }],
194
+ })
195
+ }
196
+ warnings.push(
197
+ `unrecognised protocol: every address argument observed in the call (index ${addressArgIndexes.join(', ')}) was pinned to its recorded value - the ABI is unknown, so no single argument could be identified as the recipient specifically; read this pin with less confidence than a recognised protocol's`
198
+ )
199
+ }
200
+ }
201
+
202
+ // Self-scope: a rule whose scope targets the smart account that will hold
203
+ // it lets the account authorise calls against its own governance surface
204
+ // (e.g. a recorded batch_add_signer against the account itself). Such a
205
+ // rule installs cleanly and then denies every OTHER call with OZ error
206
+ // #3002, bricking the account. Detected only when the caller supplies
207
+ // smartAccountAddress; without it the composer has nothing to compare
208
+ // scopeContract against, so composition proceeds unchanged.
209
+ if (opts.smartAccountAddress !== undefined && scopeContract === opts.smartAccountAddress) {
210
+ warnings.push(
211
+ `scope.contract (${scopeContract}) is the smart account's own address: a rule scoping ${topLevel?.fn ?? 'this call'} to the account that will hold it lets the account govern its own governance surface, and denies every other call with OZ error #3002 once installed`
212
+ )
169
213
  }
170
214
 
171
215
  // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
@@ -234,6 +234,9 @@ function synthesizeFromRecordingInner(
234
234
  const composeOpts: ComposeOptions = {
235
235
  network: opts.network,
236
236
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
237
+ ...(opts.interpreter?.smartAccountAddress !== undefined
238
+ ? { smartAccountAddress: opts.interpreter.smartAccountAddress }
239
+ : {}),
237
240
  }
238
241
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
239
242